diff --git a/.env b/.env index a7a90108a6..c042b96342 100644 --- a/.env +++ b/.env @@ -157,7 +157,7 @@ SENTRY_RELEASE=local # REO_DEV_CLIENT_ID= #### Prowler release version #### -NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.32.0 +NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.33.0 # Social login credentials SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google" diff --git a/.github/actions/setup-python-uv/action.yml b/.github/actions/setup-python-uv/action.yml index 14b9b81d15..d3293004a9 100644 --- a/.github/actions/setup-python-uv/action.yml +++ b/.github/actions/setup-python-uv/action.yml @@ -46,7 +46,7 @@ runs: env: GITHUB_TOKEN: ${{ github.token }} run: | - LATEST_COMMIT=$(curl -sf \ + LATEST_COMMIT=$(curl -sf --retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 60 \ -H "Authorization: Bearer ${GITHUB_TOKEN}" \ -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/prowler-cloud/prowler/commits/master" \ @@ -66,7 +66,7 @@ runs: env: GITHUB_TOKEN: ${{ github.token }} run: | - LATEST_COMMIT=$(curl -sf \ + LATEST_COMMIT=$(curl -sf --retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 60 \ -H "Authorization: Bearer ${GITHUB_TOKEN}" \ -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/prowler-cloud/prowler/commits/master" \ diff --git a/.github/actions/trivy-scan/action.yml b/.github/actions/trivy-scan/action.yml index 45034ddd6a..b7b758fb64 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.71.0' + version: 'v0.71.2' - 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.71.0' + version: 'v0.71.2' - name: Upload Trivy results to GitHub Security tab if: inputs.upload-sarif == 'true' && github.event_name == 'push' diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6a24af5fce..698fe470e7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -30,17 +30,18 @@ updates: # - "pip" # - "component/api" - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "monthly" - open-pull-requests-limit: 25 - target-branch: master - labels: - - "dependencies" - - "github_actions" - cooldown: - default-days: 7 + # Dependabot version updates disabled - migrated to Renovate - 2026/07/02 + # - package-ecosystem: "github-actions" + # directory: "/" + # schedule: + # interval: "monthly" + # open-pull-requests-limit: 25 + # target-branch: master + # labels: + # - "dependencies" + # - "github_actions" + # cooldown: + # default-days: 7 # Dependabot Updates are temporary disabled - 2025/03/19 # - package-ecosystem: "npm" @@ -54,17 +55,18 @@ updates: # - "npm" # - "component/ui" - - package-ecosystem: "docker" - directory: "/" - schedule: - interval: "monthly" - open-pull-requests-limit: 25 - target-branch: master - labels: - - "dependencies" - - "docker" - cooldown: - default-days: 7 + # Dependabot version updates disabled - migrated to Renovate - 2026/07/02 + # - package-ecosystem: "docker" + # directory: "/" + # schedule: + # interval: "monthly" + # open-pull-requests-limit: 25 + # target-branch: master + # labels: + # - "dependencies" + # - "docker" + # cooldown: + # default-days: 7 # - package-ecosystem: "pre-commit" # directory: "/" diff --git a/.github/renovate.json b/.github/renovate.json index be910a7f4e..d75f34620d 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -38,7 +38,7 @@ "schedule": [ "* 22-23,0-5 1 * *" ], - "enabled": false + "enabled": true }, { "description": "Minors: 8th of every 3 months, Madrid overnight window (22:00-06:00)", @@ -48,7 +48,7 @@ "schedule": [ "* 22-23,0-5 8 */3 *" ], - "enabled": false + "enabled": true }, { "description": "Majors: 15th of every 3 months, Madrid overnight window", @@ -58,7 +58,7 @@ "schedule": [ "* 22-23,0-5 15 */3 *" ], - "enabled": false + "enabled": true }, { "description": "GitHub Actions - single grouped PR, no changelog, scope=ci", diff --git a/.github/workflows/api-container-build-push.yml b/.github/workflows/api-container-build-push.yml index d4835db7d5..196a6dfc50 100644 --- a/.github/workflows/api-container-build-push.yml +++ b/.github/workflows/api-container-build-push.yml @@ -215,7 +215,7 @@ jobs: - name: Install regctl if: always() - uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main + uses: regclient/actions/regctl-installer@9a2d4216180dbb3e2dccfa60d2dd4afd98e42ec5 # main - name: Cleanup intermediate architecture tags if: always() @@ -272,27 +272,3 @@ jobs: payload-file-path: "./.github/scripts/slack-messages/container-release-completed.json" step-outcome: ${{ steps.outcome.outputs.outcome }} update-ts: ${{ needs.notify-release-started.outputs.message-ts }} - - trigger-deployment: - needs: [setup, container-build-push] - if: always() && github.event_name == 'push' && needs.setup.result == 'success' && needs.container-build-push.result == 'success' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - - steps: - - name: Harden Runner - uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 - with: - egress-policy: block - allowed-endpoints: > - api.github.com:443 - - - name: Trigger API deployment - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} - repository: ${{ secrets.CLOUD_DISPATCH }} - event-type: api-prowler-deployment - client-payload: '{"sha": "${{ github.sha }}", "short_sha": "${{ needs.setup.outputs.short-sha }}"}' diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index 36937bdc68..bbb20cdadc 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -48,7 +48,7 @@ jobs: services: postgres: - image: postgres:17@sha256:2cd82735a36356842d5eb1ef80db3ae8f1154172f0f653db48fde079b2a0b7f7 + image: postgres:17@sha256:5c855ad7b85e68e48a62f34662853f38b57c1c1d80f3a927ab58034fd6d31c5e env: POSTGRES_HOST: ${{ env.POSTGRES_HOST }} POSTGRES_PORT: ${{ env.POSTGRES_PORT }} @@ -63,7 +63,7 @@ jobs: --health-timeout 5s --health-retries 5 valkey: - image: valkey/valkey:7-alpine3.19 + image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc env: VALKEY_HOST: ${{ env.VALKEY_HOST }} VALKEY_PORT: ${{ env.VALKEY_PORT }} diff --git a/.github/workflows/find-secrets.yml b/.github/workflows/find-secrets.yml index 6f8b47d17e..ac3efaaa69 100644 --- a/.github/workflows/find-secrets.yml +++ b/.github/workflows/find-secrets.yml @@ -29,10 +29,11 @@ jobs: with: # We can't block as Trufflehog needs to verify secrets against vendors egress-policy: audit - # allowed-endpoints: > - # github.com:443 - # ghcr.io:443 - # pkg-containers.githubusercontent.com:443 + allowed-endpoints: > + github.com:443 + ghcr.io:443 + pkg-containers.githubusercontent.com:443 + www.formbucket.com:443 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/mcp-container-build-push.yml b/.github/workflows/mcp-container-build-push.yml index 784d8b9595..6eeb5734cc 100644 --- a/.github/workflows/mcp-container-build-push.yml +++ b/.github/workflows/mcp-container-build-push.yml @@ -206,7 +206,7 @@ jobs: - name: Install regctl if: always() - uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main + uses: regclient/actions/regctl-installer@9a2d4216180dbb3e2dccfa60d2dd4afd98e42ec5 # main - name: Cleanup intermediate architecture tags if: always() @@ -263,27 +263,3 @@ jobs: payload-file-path: "./.github/scripts/slack-messages/container-release-completed.json" step-outcome: ${{ steps.outcome.outputs.outcome }} update-ts: ${{ needs.notify-release-started.outputs.message-ts }} - - trigger-deployment: - needs: [setup, container-build-push] - if: always() && github.event_name == 'push' && needs.setup.result == 'success' && needs.container-build-push.result == 'success' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - - steps: - - name: Harden Runner - uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 - with: - egress-policy: block - allowed-endpoints: > - api.github.com:443 - - - name: Trigger MCP deployment - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} - repository: ${{ secrets.CLOUD_DISPATCH }} - event-type: mcp-prowler-deployment - client-payload: '{"sha": "${{ github.sha }}", "short_sha": "${{ needs.setup.outputs.short-sha }}"}' diff --git a/.github/workflows/pr-conflict-checker.yml b/.github/workflows/pr-conflict-checker.yml index ff3871bf73..a4fc2a4751 100644 --- a/.github/workflows/pr-conflict-checker.yml +++ b/.github/workflows/pr-conflict-checker.yml @@ -37,8 +37,7 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 1 - # zizmor: ignore[artipacked] - persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch + persist-credentials: false # No write token in the untrusted PR-head tree; public repo so base fetch/changed-files work unauthenticated - name: Fetch PR base ref for tj-actions/changed-files env: @@ -50,6 +49,8 @@ jobs: uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: '**' + safe_output: false # Raw paths (list read via env var, injection-safe); default escaping backslash-quotes chars like () and breaks the -f test + separator: "\n" # Newline-delimited so the reader tolerates spaces and glob chars in paths - name: Check for conflict markers id: conflict-check @@ -59,19 +60,18 @@ jobs: CONFLICT_FILES="" HAS_CONFLICTS=false - # Check each changed file for conflict markers - for file in ${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}; do - if [ -f "$file" ]; then - echo "Checking file: $file" + # Read newline-delimited paths so spaces/globs neither word-split nor glob-expand + while IFS= read -r file; do + [ -n "$file" ] || continue + [ -f "$file" ] || continue + echo "Checking file: $file" - # Look for conflict markers (more precise regex) - if grep -qE '^(<<<<<<<|=======|>>>>>>>)' "$file" 2>/dev/null; then - echo "Conflict markers found in: $file" - CONFLICT_FILES="${CONFLICT_FILES}- \`${file}\`"$'\n' - HAS_CONFLICTS=true - fi + if grep -qE '^(<<<<<<<|=======|>>>>>>>)' "$file" 2>/dev/null; then + echo "Conflict markers found in: $file" + CONFLICT_FILES="${CONFLICT_FILES}- \`${file}\`"$'\n' + HAS_CONFLICTS=true fi - done + done <<< "$STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES" if [ "$HAS_CONFLICTS" = true ]; then echo "has_conflicts=true" >> $GITHUB_OUTPUT @@ -88,18 +88,49 @@ jobs: env: STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} + - name: Check base-branch mergeability + id: merge-check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + MERGEABLE=null + + # GitHub computes mergeability async, so .mergeable is null until ready; poll until resolved + for attempt in 1 2 3 4 5; do + MERGEABLE=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.mergeable') + if [ "$MERGEABLE" != "null" ]; then + break + fi + echo "Attempt ${attempt}: mergeability not computed yet, retrying..." + sleep 3 + done + + # Keep 'unknown' distinct from 'clean' so we never assert a clean merge we could not confirm + case "$MERGEABLE" in + false) STATUS=conflict; echo "PR branch cannot be merged cleanly into its base branch" ;; + true) STATUS=clean; echo "PR branch merges cleanly into its base branch" ;; + *) STATUS=unknown; echo "::warning::Mergeability did not resolve after retries; leaving it undetermined" ;; + esac + + echo "merge_status=${STATUS}" >> "$GITHUB_OUTPUT" + - name: Manage conflict label env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} HAS_CONFLICTS: ${{ steps.conflict-check.outputs.has_conflicts }} + MERGE_STATUS: ${{ steps.merge-check.outputs.merge_status }} run: | LABEL_NAME="has-conflicts" - # Add or remove label based on conflict status - if [ "$HAS_CONFLICTS" = "true" ]; then + if [ "$HAS_CONFLICTS" = "true" ] || [ "$MERGE_STATUS" = "conflict" ]; then echo "Adding conflict label to PR #${PR_NUMBER}..." gh pr edit "$PR_NUMBER" --add-label "$LABEL_NAME" --repo ${{ github.repository }} || true + elif [ "$MERGE_STATUS" = "unknown" ]; then + # Don't drop the label on an undetermined merge state; a later run will settle it + echo "Mergeability undetermined; leaving label unchanged" else echo "Removing conflict label from PR #${PR_NUMBER}..." gh pr edit "$PR_NUMBER" --remove-label "$LABEL_NAME" --repo ${{ github.repository }} || true @@ -121,20 +152,25 @@ jobs: edit-mode: replace body: | - ${{ steps.conflict-check.outputs.has_conflicts == 'true' && '⚠️ **Conflict Markers Detected**' || '✅ **Conflict Markers Resolved**' }} - - ${{ steps.conflict-check.outputs.has_conflicts == 'true' && format('This pull request contains unresolved conflict markers in the following files: + ${{ (steps.conflict-check.outputs.has_conflicts == 'true' || steps.merge-check.outputs.merge_status == 'conflict') && '⚠️ **Conflicts Detected**' || (steps.merge-check.outputs.merge_status == 'unknown' && 'ℹ️ **Conflict Check Incomplete**' || '✅ **No Conflicts**') }} + ${{ steps.conflict-check.outputs.has_conflicts == 'true' && format(' + **Conflict markers** are present in the following files: {0} - - Please resolve these conflicts by: - 1. Locating the conflict markers: `<<<<<<<`, `=======`, and `>>>>>>>` - 2. Manually editing the files to resolve the conflicts - 3. Removing all conflict markers - 4. Committing and pushing the changes', steps.conflict-check.outputs.conflict_files) || 'All conflict markers have been successfully resolved in this pull request.' }} + Resolve them by removing every `<<<<<<<`, `=======`, and `>>>>>>>` marker, then commit and push.', steps.conflict-check.outputs.conflict_files) || '' }} + ${{ steps.merge-check.outputs.merge_status == 'conflict' && ' + **Merge conflict with the base branch.** This PR cannot be merged cleanly. Update your branch with the latest base (rebase or merge) and resolve the conflicts.' || '' }} + ${{ steps.merge-check.outputs.merge_status == 'unknown' && ' + GitHub had not finished computing mergeability, so base-branch conflict status could not be verified on this run.' || '' }} + ${{ (steps.conflict-check.outputs.has_conflicts != 'true' && steps.merge-check.outputs.merge_status == 'clean') && ' + No conflict markers, and the branch merges cleanly into its base.' || '' }} - name: Fail workflow if conflicts detected - if: steps.conflict-check.outputs.has_conflicts == 'true' + if: steps.conflict-check.outputs.has_conflicts == 'true' || steps.merge-check.outputs.merge_status == 'conflict' + env: + HAS_CONFLICTS: ${{ steps.conflict-check.outputs.has_conflicts }} + MERGE_STATUS: ${{ steps.merge-check.outputs.merge_status }} run: | - echo "::error::Workflow failed due to conflict markers detected in the PR" + [ "$HAS_CONFLICTS" = "true" ] && echo "::error::Conflict markers detected in changed files" + [ "$MERGE_STATUS" = "conflict" ] && echo "::error::PR branch has merge conflicts with the base branch" exit 1 diff --git a/.github/workflows/pr-merged.yml b/.github/workflows/pr-merged.yml index 2a1f3d77f1..fc88a69e08 100644 --- a/.github/workflows/pr-merged.yml +++ b/.github/workflows/pr-merged.yml @@ -56,6 +56,6 @@ jobs: "PROWLER_PR_BODY": ${{ toJson(github.event.pull_request.body) }}, "PROWLER_PR_URL": ${{ toJson(github.event.pull_request.html_url) }}, "PROWLER_PR_MERGED_BY": "${{ github.event.pull_request.merged_by.login }}", - "PROWLER_PR_BASE_BRANCH": "${{ github.event.pull_request.base.ref }}", - "PROWLER_PR_HEAD_BRANCH": "${{ github.event.pull_request.head.ref }}" + "PROWLER_PR_BASE_BRANCH": ${{ toJson(github.event.pull_request.base.ref) }}, + "PROWLER_PR_HEAD_BRANCH": ${{ toJson(github.event.pull_request.head.ref) }} } diff --git a/.github/workflows/sdk-container-build-push.yml b/.github/workflows/sdk-container-build-push.yml index 2dbaeec4a5..b8be170820 100644 --- a/.github/workflows/sdk-container-build-push.yml +++ b/.github/workflows/sdk-container-build-push.yml @@ -138,6 +138,7 @@ jobs: permissions: contents: read packages: write + id-token: write steps: - name: Harden Runner @@ -147,6 +148,8 @@ jobs: allowed-endpoints: > api.ecr-public.us-east-1.amazonaws.com:443 public.ecr.aws:443 + sts.amazonaws.com:443 + sts.us-east-1.amazonaws.com:443 registry-1.docker.io:443 production.cloudflare.docker.com:443 production.cloudfront.docker.com:443 @@ -173,14 +176,16 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Login to Public ECR - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 with: - registry: public.ecr.aws - username: ${{ secrets.PUBLIC_ECR_AWS_ACCESS_KEY_ID }} - password: ${{ secrets.PUBLIC_ECR_AWS_SECRET_ACCESS_KEY }} - env: - AWS_REGION: ${{ env.AWS_REGION }} + aws-region: us-east-1 + role-to-assume: ${{ secrets.PUBLIC_ECR_IAM_ROLE_ARN }} + + - name: Login to Public ECR + uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2.1.6 + with: + registry-type: public - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 @@ -206,6 +211,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + id-token: write steps: - name: Harden Runner @@ -221,6 +227,8 @@ jobs: github.com:443 release-assets.githubusercontent.com:443 api.ecr-public.us-east-1.amazonaws.com:443 + sts.amazonaws.com:443 + sts.us-east-1.amazonaws.com:443 - name: Login to DockerHub @@ -229,14 +237,16 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Login to Public ECR - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 with: - registry: public.ecr.aws - username: ${{ secrets.PUBLIC_ECR_AWS_ACCESS_KEY_ID }} - password: ${{ secrets.PUBLIC_ECR_AWS_SECRET_ACCESS_KEY }} - env: - AWS_REGION: ${{ env.AWS_REGION }} + aws-region: us-east-1 + role-to-assume: ${{ secrets.PUBLIC_ECR_IAM_ROLE_ARN }} + + - name: Login to Public ECR + uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2.1.6 + with: + registry-type: public - name: Create and push manifests for push event if: github.event_name == 'push' @@ -299,7 +309,7 @@ jobs: - name: Install regctl if: always() - uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main + uses: regclient/actions/regctl-installer@9a2d4216180dbb3e2dccfa60d2dd4afd98e42ec5 # main - name: Cleanup intermediate architecture tags if: always() diff --git a/.github/workflows/test-impact-analysis.yml b/.github/workflows/test-impact-analysis.yml index 625d0f483f..ead669ae29 100644 --- a/.github/workflows/test-impact-analysis.yml +++ b/.github/workflows/test-impact-analysis.yml @@ -73,7 +73,7 @@ jobs: - name: Setup Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: '3.12' + python-version: '3.12.13' - name: Install PyYAML run: pip install pyyaml diff --git a/.github/workflows/ui-container-build-push.yml b/.github/workflows/ui-container-build-push.yml index 3210f6e998..95214a233a 100644 --- a/.github/workflows/ui-container-build-push.yml +++ b/.github/workflows/ui-container-build-push.yml @@ -201,7 +201,7 @@ jobs: - name: Install regctl if: always() - uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main + uses: regclient/actions/regctl-installer@9a2d4216180dbb3e2dccfa60d2dd4afd98e42ec5 # main - name: Cleanup intermediate architecture tags if: always() @@ -258,27 +258,3 @@ jobs: payload-file-path: "./.github/scripts/slack-messages/container-release-completed.json" step-outcome: ${{ steps.outcome.outputs.outcome }} update-ts: ${{ needs.notify-release-started.outputs.message-ts }} - - trigger-deployment: - needs: [setup, container-build-push] - if: always() && github.event_name == 'push' && needs.setup.result == 'success' && needs.container-build-push.result == 'success' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - - steps: - - name: Harden Runner - uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 - with: - egress-policy: block - allowed-endpoints: > - api.github.com:443 - - - name: Trigger UI deployment - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} - repository: ${{ secrets.CLOUD_DISPATCH }} - event-type: ui-prowler-deployment - client-payload: '{"sha": "${{ github.sha }}", "short_sha": "${{ needs.setup.outputs.short-sha }}"}' diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 2dc50a26bb..f406b24e81 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -37,8 +37,12 @@ jobs: allowed-endpoints: > github.com:443 registry.npmjs.org:443 + nodejs.org:443 fonts.googleapis.com:443 fonts.gstatic.com:443 + api.iconify.design:443 + api.simplesvg.com:443 + api.unisvg.com:443 api.github.com:443 release-assets.githubusercontent.com:443 cdn.playwright.dev:443 diff --git a/.gitignore b/.gitignore index 4d73ffe990..6c11a8698c 100644 --- a/.gitignore +++ b/.gitignore @@ -169,3 +169,7 @@ GEMINI.md # Claude Code .claude/* + +# Docker +docker-compose.override.yml +docker-compose-dev.override.yml diff --git a/.trivyignore b/.trivyignore index 117925354f..744c94a193 100644 --- a/.trivyignore +++ b/.trivyignore @@ -52,6 +52,19 @@ CVE-2026-43185 pkg:linux-libc-dev exp:2026-07-15 CVE-2023-45853 pkg:zlib1g exp:2026-07-15 CVE-2023-45853 pkg:zlib1g-dev exp:2026-07-15 +# CVE-2026-55200 — libssh2 out-of-bounds write in ssh2_transport_read() due to +# an unchecked packet_length field in transport.c (heap corruption, possible RCE). +# Package: libssh2-1. +# Why ignored: libssh2-1 is pulled in only as a transitive dependency of libcurl4 +# (installed in the SDK Dockerfile for the networking/PowerShell stack). The +# vulnerable path is reached exclusively when libssh2 acts as an SSH/SCP/SFTP +# client parsing transport packets from a server. Prowler never uses libcurl's +# SSH/SCP/SFTP transports; it talks to cloud provider HTTPS endpoints only, so the +# affected code is unreachable at runtime. Fixed upstream in libssh2 commit +# 97acf3df (PR #2052); no Debian bookworm fix is available yet. +# Ref: https://security-tracker.debian.org/tracker/CVE-2026-55200 +CVE-2026-55200 pkg:libssh2-1 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. diff --git a/AGENTS.md b/AGENTS.md index c9d63a8c27..763b3f10e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,6 +114,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Review PR requirements: template, title conventions, changelog gate | `prowler-pr` | | Review changelog format and conventions | `prowler-changelog` | | Reviewing JSON:API compliance | `jsonapi` | +| Reviewing Prowler UI components | `prowler-ui` | | Reviewing compliance framework PRs | `prowler-compliance-review` | | Running makemigrations or pgmakemigrations | `django-migration-psql` | | Syncing compliance framework with upstream catalog | `prowler-compliance` | diff --git a/Dockerfile b/Dockerfile index 678cb2b36f..88183f652c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS build +FROM python:3.12.13-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b 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.71.0 +ARG TRIVY_VERSION=0.71.2 ENV TRIVY_VERSION=${TRIVY_VERSION} ARG ZIZMOR_VERSION=1.24.1 @@ -95,6 +95,18 @@ RUN uv sync --locked --compile-bytecode && \ # Install PowerShell modules RUN .venv/bin/python prowler/providers/m365/lib/powershell/m365_powershell.py +USER root + +# Remove build-only packages from the final image after Python dependencies are installed. +RUN apt-get purge -y --auto-remove \ + build-essential \ + pkg-config \ + libzstd-dev \ + zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +USER prowler + # Remove deprecated dash dependencies RUN pip uninstall dash-html-components -y && \ pip uninstall dash-core-components -y diff --git a/README.md b/README.md index ef5ab910d4..e2314d8a69 100644 --- a/README.md +++ b/README.md @@ -83,16 +83,35 @@ prowler dashboard ## Attack Paths -Attack Paths automatically extends every completed AWS scan with a Neo4j graph that combines Cartography's cloud inventory with Prowler findings. The feature runs in the API worker after each scan and therefore requires: +Attack Paths automatically extends every completed AWS scan with a graph that combines Cartography's cloud inventory with Prowler findings. The feature runs in the API worker after each scan. -- An accessible Neo4j instance (the Docker Compose files already ships a `neo4j` service). -- The following environment variables so Django and Celery can connect: +Two graph backends are supported as the long-lived sink: - | Variable | Description | Default | - | --- | --- | --- | - | `NEO4J_HOST` | Hostname used by the API containers. | `neo4j` | - | `NEO4J_PORT` | Bolt port exposed by Neo4j. | `7687` | - | `NEO4J_USER` / `NEO4J_PASSWORD` | Credentials with rights to create per-tenant databases. | `neo4j` / `neo4j_password` | +- **Neo4j** (default; the Docker Compose files already ship a `neo4j` service). +- **Amazon Neptune** (cloud-managed; opt-in). + +Select the sink with `ATTACK_PATHS_SINK_DATABASE` (`neo4j` or `neptune`; default `neo4j`). + +> Note: Cartography ingestion always uses a temporary Neo4j database, regardless of the configured sink. The `NEO4J_*` variables below must remain set even when `ATTACK_PATHS_SINK_DATABASE=neptune`. + +### Neo4j sink + +| Variable | Description | Default | +| --- | --- | --- | +| `NEO4J_HOST` | Hostname used by the API containers. | `neo4j` | +| `NEO4J_PORT` | Bolt port exposed by Neo4j. | `7687` | +| `NEO4J_USER` / `NEO4J_PASSWORD` | Credentials with rights to create per-tenant databases. | `neo4j` / `neo4j_password` | + +### Neptune sink + +| Variable | Description | Default | +| --- | --- | --- | +| `NEPTUNE_WRITER_ENDPOINT` | Bolt host for the Neptune writer instance. Required when sink is `neptune`. | _empty_ | +| `NEPTUNE_READER_ENDPOINT` | Optional reader endpoint for read-only queries. Falls back to the writer when unset. | _empty_ | +| `NEPTUNE_PORT` | Bolt port exposed by Neptune. | `8182` | +| `AWS_REGION` | Region the Neptune cluster lives in. Required when sink is `neptune`. | _empty_ | + +Neptune authenticates with SigV4 using the standard boto3 credential chain. The worker's IAM role (or `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`) supplies the credentials. There is no Neptune password variable. Every AWS provider scan will enqueue an Attack Paths ingestion job automatically. Other cloud providers will be added in future iterations. @@ -104,27 +123,27 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically | Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) | Support | Interface | |---|---|---|---|---|---|---| -| AWS | 613 | 86 | 46 | 19 | Official | UI, API, CLI | -| Azure | 190 | 22 | 20 | 16 | Official | UI, API, CLI | -| GCP | 109 | 20 | 18 | 12 | Official | UI, API, CLI | -| Kubernetes | 90 | 7 | 7 | 11 | Official | UI, API, CLI | -| GitHub | 24 | 3 | 1 | 5 | Official | UI, API, CLI | -| M365 | 107 | 10 | 4 | 10 | Official | UI, API, CLI | -| OCI | 52 | 14 | 4 | 10 | Official | UI, API, CLI | -| Alibaba Cloud | 63 | 9 | 5 | 9 | Official | UI, API, CLI | -| Cloudflare | 29 | 3 | 1 | 5 | Official | UI, API, CLI | +| AWS | 615 | 86 | 47 | 19 | Official | UI, API, CLI | +| Azure | 190 | 22 | 21 | 16 | Official | UI, API, CLI | +| GCP | 109 | 20 | 19 | 12 | Official | UI, API, CLI | +| Kubernetes | 90 | 7 | 8 | 11 | Official | UI, API, CLI | +| GitHub | 24 | 3 | 2 | 5 | Official | UI, API, CLI | +| M365 | 109 | 10 | 6 | 10 | Official | UI, API, CLI | +| OCI | 52 | 14 | 5 | 10 | Official | UI, API, CLI | +| Alibaba Cloud | 63 | 9 | 6 | 9 | Official | UI, API, CLI | +| Cloudflare | 29 | 3 | 2 | 5 | Official | UI, API, CLI | | IaC | [See `trivy` docs.](https://trivy.dev/latest/docs/coverage/iac/) | N/A | N/A | N/A | Official | UI, API, CLI | -| MongoDB Atlas | 10 | 3 | 0 | 8 | Official | UI, API, CLI | +| MongoDB Atlas | 10 | 3 | 1 | 8 | Official | UI, API, CLI | | LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI | | Image | N/A | N/A | N/A | N/A | Official | CLI, API | -| Google Workspace | 65 | 11 | 2 | 6 | Official | UI, API, CLI | -| OpenStack | 34 | 5 | 0 | 9 | Official | UI, API, CLI | -| Vercel | 26 | 6 | 0 | 8 | Official | UI, API, CLI | -| Okta | 29 | 8 | 1 | 2 | Official | UI, API, CLI | -| Linode [Contact us](https://prowler.com/contact) | 10 | 3 | 0 | 4 | Unofficial | CLI | -| Scaleway [Contact us](https://prowler.com/contact) | 1 | 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 | +| Google Workspace | 65 | 11 | 3 | 6 | Official | UI, API, CLI | +| OpenStack | 34 | 5 | 1 | 9 | Official | UI, API, CLI | +| Vercel | 26 | 6 | 1 | 8 | Official | UI, API, CLI | +| Okta | 29 | 8 | 2 | 2 | Official | UI, API, CLI | +| Linode [Contact us](https://prowler.com/contact) | 10 | 3 | 1 | 4 | Unofficial | CLI | +| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 1 | 1 | Unofficial | CLI | +| StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 1 | 3 | Unofficial | CLI | +| NHN | 6 | 2 | 2 | 0 | Unofficial | CLI | > [!Note] > The numbers in the table are updated periodically. diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 321534c8f5..75c95f49e6 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,6 +2,34 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.33.0] (Prowler v5.32.0) + +### 🚀 Added + +- Timestamp precision support for `/api/v1/findings` `inserted_at` and `updated_at` filters [(#11754)](https://github.com/prowler-cloud/prowler/pull/11754) + +### 🔄 Changed + +- Attack Paths: AWS Neptune is now supported as a persistent sink database, selectable via `ATTACK_PATHS_SINK_DATABASE=neptune` (default `neo4j`), Cartography's (bumped to 0.138.1) per-scan ingest database stays on Neo4j [(#11524)](https://github.com/prowler-cloud/prowler/pull/11524) +- Attack Paths: Scan task now checks the ingest Neo4j database and configured graph sink before starting graph ingestion [(#11743)](https://github.com/prowler-cloud/prowler/pull/11743) +- Disable PowerShell telemetry in the API container image [(#11746)](https://github.com/prowler-cloud/prowler/pull/11746) + +### 🐞 Fixed + +- Attack Paths: Provider graph cleanup now deletes Neo4j and Neptune relationships in directed batches before deleting nodes [(#11755)](https://github.com/prowler-cloud/prowler/pull/11755) +- `scan-perform` no longer reports an error when a provider is deleted during a running scan [(#11696)](https://github.com/prowler-cloud/prowler/pull/11696) + + +--- + +## [1.32.1] (Prowler v5.31.1) + +### 🐞 Fixed + +- API key auth no longer mutates `TenantAPIKey.objects` during admin DB lookups [(#11686)](https://github.com/prowler-cloud/prowler/pull/11686) + +--- + ## [1.32.0] (Prowler v5.31.0) ### 🚀 Added diff --git a/api/Dockerfile b/api/Dockerfile index bb9da0b280..0b5b0d7a27 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,11 +1,13 @@ -FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS build +FROM python:3.12.13-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS build LABEL maintainer="https://github.com/prowler-cloud/api" ARG POWERSHELL_VERSION=7.5.0 ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} +# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com) +ENV POWERSHELL_TELEMETRY_OPTOUT=1 -ARG TRIVY_VERSION=0.71.0 +ARG TRIVY_VERSION=0.71.2 ENV TRIVY_VERSION=${TRIVY_VERSION} ARG ZIZMOR_VERSION=1.24.1 @@ -102,6 +104,23 @@ RUN uv sync --locked --no-install-project && \ RUN .venv/bin/python .venv/lib/python3.12/site-packages/prowler/providers/m365/lib/powershell/m365_powershell.py +USER root + +# Remove build-only packages from the final image after Python dependencies are installed. +RUN apt-get purge -y --auto-remove \ + gcc \ + g++ \ + make \ + libxml2-dev \ + libxmlsec1-dev \ + pkg-config \ + libtool \ + libxslt1-dev \ + python3-dev \ + && rm -rf /var/lib/apt/lists/* + +USER prowler + COPY --chown=prowler:prowler src/backend/ ./backend/ COPY --chown=prowler:prowler docker-entrypoint.sh ./docker-entrypoint.sh diff --git a/api/pyproject.toml b/api/pyproject.toml index 4036aac9a9..9638d5277f 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -58,7 +58,7 @@ dependencies = [ "matplotlib (==3.10.8)", "reportlab (==4.4.10)", "neo4j (==6.1.0)", - "cartography (==0.135.0)", + "cartography (==0.138.1)", "gevent (==25.9.1)", "werkzeug (==3.1.7)", "sqlparse (==0.5.5)", @@ -71,7 +71,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.33.0" +version = "1.34.0" # Shared ruff baseline (kept in sync with mcp_server/pyproject.toml). # target-version tracks this project's lowest supported Python. @@ -193,7 +193,7 @@ constraint-dependencies = [ "blinker==1.9.0", "boto3==1.40.61", "botocore==1.40.61", - "cartography==0.135.0", + "cartography==0.138.1", "celery==5.6.2", "certifi==2026.1.4", "cffi==2.0.0", @@ -218,7 +218,6 @@ constraint-dependencies = [ "debugpy==1.8.20", "decorator==5.2.1", "defusedxml==0.7.1", - "detect-secrets==1.5.0", "dill==0.4.1", "distro==1.9.0", "dj-rest-auth==7.0.1", @@ -301,6 +300,7 @@ constraint-dependencies = [ "jsonschema==4.23.0", "jsonschema-specifications==2025.9.1", "keystoneauth1==5.13.0", + "kingfisher-bin==1.104.0", "kiwisolver==1.4.9", "knack==0.11.0", "kombu==5.6.2", @@ -447,7 +447,7 @@ constraint-dependencies = [ "wcwidth==0.5.3", "websocket-client==1.9.0", "werkzeug==3.1.7", - "workos==6.0.4", + "workos==6.0.8", "wrapt==1.17.3", "xlsxwriter==3.2.9", "xmlsec==1.3.17", @@ -458,8 +458,13 @@ constraint-dependencies = [ "zope-interface==8.2", "zstd==1.5.7.3" ] -# prowler@master needs okta==3.4.2; cartography 0.135.0 declares okta<1.0.0 for an -# integration prowler does not import. +# prowler@master needs okta==3.4.2, but cartography 0.138.1 requires okta<1.0.0. +# Attack Paths does not ingest Okta today, so override the Cartography +# dependency to the Prowler pin. +# +# prowler@master needs azure-mgmt-containerservice==34.1.0, but cartography +# 0.138.1 requires azure-mgmt-containerservice>=41.0.0. Attack Paths does not +# ingest Azure today, so override the Cartography dependency to the Prowler pin. # # prowler@master hard-pins microsoft-kiota-abstractions==1.9.2 in [project.dependencies]. # The microsoft-kiota-http security bump to 1.9.9 (GHSA-7j59-v9qr-6fq9) requires @@ -475,6 +480,7 @@ constraint-dependencies = [ # that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive. override-dependencies = [ "okta==3.4.2", + "azure-mgmt-containerservice==34.1.0", "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 90ba96c124..1aa0f8f54f 100644 --- a/api/src/backend/api/apps.py +++ b/api/src/backend/api/apps.py @@ -42,9 +42,6 @@ class ApiConfig(AppConfig): ): self._ensure_crypto_keys() - # 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): """ Orchestrator method that ensures all required cryptographic keys are present. diff --git a/api/src/backend/api/attack_paths/cypher_sanitizer.py b/api/src/backend/api/attack_paths/cypher_sanitizer.py index f08172114e..35752b3ec9 100644 --- a/api/src/backend/api/attack_paths/cypher_sanitizer.py +++ b/api/src/backend/api/attack_paths/cypher_sanitizer.py @@ -4,10 +4,10 @@ Cypher sanitizer for custom (user-supplied) Attack Paths queries. Two responsibilities: 1. **Validation** - reject queries containing SSRF or dangerous procedure - patterns (defense-in-depth; the primary control is ``neo4j.READ_ACCESS``). + patterns (defense-in-depth; the primary control is `neo4j.READ_ACCESS`). 2. **Provider-scoped label injection** - inject a dynamic - ``_Provider_{uuid}`` label into every node pattern so the database can + `_Provider_{uuid}` label into every node pattern so the database can use its native label index for provider isolation. Label-injection pipeline: @@ -25,13 +25,13 @@ from rest_framework.exceptions import ValidationError from tasks.jobs.attack_paths.config import get_provider_label # Step 1 - String / comment protection -# Single combined regex: strings first, then line comments. +# Single combined regex: strings first, then line comments # The regex engine finds the leftmost match, so a string like 'https://prowler.com' -# is consumed as a string before the // inside it can match as a comment. +# is consumed as a string before the // inside it can match as a comment _PROTECTED_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*") # Step 2 - Clause splitting -# OPTIONAL MATCH must come before MATCH to avoid partial matching. +# `OPTIONAL MATCH` must come before `MATCH` to avoid partial matching _CLAUSE_RE = re.compile( r"\b(OPTIONAL\s+MATCH|MATCH|WHERE|RETURN|WITH|ORDER\s+BY" r"|SKIP|LIMIT|UNION|UNWIND|CALL)\b", @@ -39,10 +39,10 @@ _CLAUSE_RE = re.compile( ) # Pass A - Labeled node patterns (all segments) -# Matches node patterns that have at least one :Label. -# (? str: node pattern. """ label = get_provider_label(provider_id) + return inject_label(cypher, label) + + +def inject_label(cypher: str, label: str) -> str: + """Rewrite a Cypher query to append a label to every node pattern.""" # Step 1: Protect strings and comments (single pass, leftmost-first) protected: list[str] = [] @@ -134,9 +139,7 @@ def inject_provider_label(cypher: str, provider_id: str) -> str: return work -# --------------------------------------------------------------------------- # Validation -# --------------------------------------------------------------------------- # Patterns that indicate SSRF or dangerous procedure calls # Defense-in-depth layer - the primary control is `neo4j.READ_ACCESS` diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py index 4745cf79a1..3a33b964b7 100644 --- a/api/src/backend/api/attack_paths/database.py +++ b/api/src/backend/api/attack_paths/database.py @@ -1,261 +1,32 @@ -import atexit -import logging -import threading -from collections.abc import Iterator -from contextlib import contextmanager +"""Backwards-compatible facade over the ingest and sink modules. + +Historically this module owned a single Neo4j driver used for both the +cartography temp database and the per-tenant sink database. The port to AWS +Neptune split those roles: the cartography ingest (temp) database is always +Neo4j and lives in `api.attack_paths.ingest`; the sink is configurable +(Neo4j or Neptune) and lives in `api.attack_paths.sink`. This shim preserves +the public API that `tasks/` and `api/v1/views.py` already depend on, and +dispatches to the right module by database-name prefix. + +A database name starting with `db-tmp-scan-` is a cartography temp DB and +routes to ingest. Everything else routes to the configured sink. +""" + +from contextlib import AbstractContextManager from typing import Any from uuid import UUID -import neo4j -import neo4j.exceptions -from api.attack_paths.retryable_session import RetryableSession +import neo4j # noqa: F401 - kept for tests that patch api.attack_paths.database.neo4j +from api.attack_paths import ingest +from api.attack_paths import sink as sink_module from config.env import env -from django.conf import settings -from tasks.jobs.attack_paths.config import ( - BATCH_SIZE, - PROVIDER_RESOURCE_LABEL, - get_provider_label, +from django.conf import ( + settings, # noqa: F401 - kept for tests that patch ...database.settings ) -# Without this Celery goes crazy with Neo4j logging -logging.getLogger("neo4j").setLevel(logging.ERROR) -logging.getLogger("neo4j").propagate = False - -SERVICE_UNAVAILABLE_MAX_RETRIES = env.int( - "ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES", default=3 -) -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", - "Neo.ClientError.Procedure.ProcedureNotFound", -] -CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement." -# Module-level process-wide driver singleton -_driver: neo4j.Driver | None = None -_lock = threading.Lock() - -# Base Neo4j functions - - -def get_uri() -> str: - host = settings.DATABASES["neo4j"]["HOST"] - port = settings.DATABASES["neo4j"]["PORT"] - return f"bolt://{host}:{port}" - - -def init_driver() -> neo4j.Driver: - global _driver - if _driver is not None: - return _driver - - with _lock: - if _driver is None: - uri = get_uri() - config = settings.DATABASES["neo4j"] - - 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, - ) - # 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) - - return _driver - - -def get_driver() -> neo4j.Driver: - return init_driver() - - -def close_driver() -> None: # TODO: Use it - global _driver - with _lock: - if _driver is not None: - try: - _driver.close() - - finally: - _driver = None - - -@contextmanager -def get_session( - database: str | None = None, default_access_mode: str | None = None -) -> Iterator[RetryableSession]: - session_wrapper: RetryableSession | None = None - - try: - session_wrapper = RetryableSession( - session_factory=lambda: get_driver().session( - database=database, default_access_mode=default_access_mode - ), - max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES, - ) - yield session_wrapper - - except neo4j.exceptions.Neo4jError as exc: - if ( - default_access_mode == neo4j.READ_ACCESS - and exc.code - and exc.code in READ_EXCEPTION_CODES - ): - message = "Read query not allowed" - code = READ_EXCEPTION_CODES[0] - raise WriteQueryNotAllowedException(message=message, code=code) - - message = exc.message if exc.message is not None else str(exc) - - if exc.code and exc.code.startswith(CLIENT_STATEMENT_EXCEPTION_PREFIX): - raise ClientStatementException(message=message, code=exc.code) - - raise GraphDatabaseQueryException(message=message, code=exc.code) - - finally: - if session_wrapper is not None: - session_wrapper.close() - - -def execute_read_query( - database: str, - cypher: str, - parameters: dict[str, Any] | None = None, -) -> neo4j.graph.Graph: - with get_session(database, default_access_mode=neo4j.READ_ACCESS) as session: - - def _run(tx: neo4j.ManagedTransaction) -> neo4j.graph.Graph: - result = tx.run( - cypher, parameters or {}, timeout=READ_QUERY_TIMEOUT_SECONDS - ) - return result.graph() - - return session.execute_read(_run) - - -def create_database(database: str) -> None: - query = "CREATE DATABASE $database IF NOT EXISTS" - parameters = {"database": database} - - with get_session() as session: - session.run(query, parameters) - - -def drop_database(database: str) -> None: - query = f"DROP DATABASE `{database}` IF EXISTS DESTROY DATA" - - with get_session() as session: - session.run(query) - - -def drop_subgraph(database: str, provider_id: str) -> int: - """ - Delete all nodes for a provider from the tenant database. - - 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) - deleted_nodes = 0 - - 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 - DELETE n - RETURN COUNT(n) AS deleted_nodes_count - """, - {"batch_size": BATCH_SIZE}, - ) - deleted_count = result.single().get("deleted_nodes_count", 0) - deleted_nodes += deleted_count - - except GraphDatabaseQueryException as exc: - if exc.code == "Neo.ClientError.Database.DatabaseNotFound": - return 0 - raise - - return deleted_nodes - - -def has_provider_data(database: str, provider_id: str) -> bool: - """ - Check if any ProviderResource node exists for this provider. - - Returns `False` if the database doesn't exist. - """ - provider_label = get_provider_label(provider_id) - query = f"MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) RETURN 1 LIMIT 1" - - try: - with get_session(database, default_access_mode=neo4j.READ_ACCESS) as session: - result = session.run(query) - return result.single() is not None - - except GraphDatabaseQueryException as exc: - if exc.code == "Neo.ClientError.Database.DatabaseNotFound": - return False - raise - - -def clear_cache(database: str) -> None: - query = "CALL db.clearQueryCaches()" - - try: - with get_session(database) as session: - session.run(query) - - except GraphDatabaseQueryException as exc: - logging.warning(f"Failed to clear query cache for database `{database}`: {exc}") - - -# Neo4j functions related to Prowler + Cartography - - -def get_database_name(entity_id: str | UUID, temporary: bool = False) -> str: - prefix = "tmp-scan" if temporary else "tenant" - return f"db-{prefix}-{str(entity_id).lower()}" +TEMP_DB_PREFIX = "db-tmp-scan-" # Exceptions @@ -270,7 +41,6 @@ class GraphDatabaseQueryException(Exception): def __str__(self) -> str: if self.code: return f"{self.code}: {self.message}" - return self.message @@ -280,3 +50,177 @@ class WriteQueryNotAllowedException(GraphDatabaseQueryException): class ClientStatementException(GraphDatabaseQueryException): pass + + +# Routing + + +def _is_ingest_database(database: str | None) -> bool: + return bool(database) and database.startswith(TEMP_DB_PREFIX) + + +# Driver lifecycle + + +def init_driver() -> Any: + """Initialize the configured sink backend. + + The ingest driver (Neo4j for cartography temp DBs) stays lazy: it is + only initialized when a temp-DB operation actually runs, which never + happens on API pods. + """ + return sink_module.init() + + +def close_driver() -> None: + """Close every driver held by this process.""" + sink_module.close() + ingest.close_driver() + + +def get_driver() -> neo4j.Driver: + """Return the sink backend's underlying driver. + + Only meaningful for the Neo4j sink (where the backend has a single Neo4j + driver). On Neptune this returns the writer driver. Kept for tests and + legacy call-sites; prefer `get_session` for new code. + """ + backend = sink_module.get_backend() + + # Neo4jSink exposes get_driver(); NeptuneSink exposes get_writer() + if hasattr(backend, "get_driver"): + return backend.get_driver() + + if hasattr(backend, "get_writer"): + return backend.get_writer() + + raise RuntimeError("Active sink backend does not expose a driver handle") + + +def verify_connectivity() -> None: + """Raise if the configured graph database is unreachable on the API read path. + + Backend-agnostic entry point for the readiness probe: Neo4j verifies its + driver, Neptune verifies the reader endpoint. + """ + sink_module.get_backend().verify_connectivity() + + +def verify_scan_databases_available() -> None: + """Raise if either graph database needed by an Attack Paths scan is unavailable.""" + errors: list[str] = [] + first_error: Exception | None = None + + try: + ingest.get_driver().verify_connectivity() + except Exception as exc: + errors.append(f"ingest Neo4j: {exc}") + first_error = exc + + try: + get_driver().verify_connectivity() + except Exception as exc: + errors.append(f"sink {settings.ATTACK_PATHS_SINK_DATABASE}: {exc}") + if first_error is None: + first_error = exc + + if errors: + raise RuntimeError( + "Attack Paths graph database unavailable before scan start: " + + "; ".join(errors) + ) from first_error + + +def get_uri() -> str: + """Return the sink URI. Retained for backwards compatibility.""" + if settings.ATTACK_PATHS_SINK_DATABASE == "neptune": + cfg = settings.DATABASES["neptune"] + return f"bolt+s://{cfg['WRITER_ENDPOINT']}:{cfg['PORT']}" + + cfg = settings.DATABASES["neo4j"] + return f"bolt://{cfg['HOST']}:{cfg['PORT']}" + + +def get_ingest_uri() -> str: + """Neo4j URI for the cartography temp (ingest) database, which is always + Neo4j regardless of the configured sink.""" + return ingest.get_uri() + + +# Session API + + +def get_session( + database: str | None = None, + default_access_mode: str | None = None, +) -> AbstractContextManager: + """Return a session against the right backend. + + - `database` names starting with `db-tmp-scan-` always go to ingest. + - No database name → ingest (used for CREATE / DROP DATABASE admin ops). + - Any other name → sink. + """ + if _is_ingest_database(database) or database is None: + return ingest.get_session( + database=database, default_access_mode=default_access_mode + ) + + return sink_module.get_backend().get_session( + database=database, default_access_mode=default_access_mode + ) + + +def execute_read_query( + database: str, + cypher: str, + parameters: dict[str, Any] | None = None, +) -> neo4j.graph.Graph: + """Read-only query against the sink.""" + return sink_module.get_backend().execute_read_query(database, cypher, parameters) + + +def create_database(database: str) -> None: + """Create a database. Temp DBs always land on ingest (Neo4j). + + On the Neo4j sink, tenant DBs also route to ingest because both drivers + connect to the same Neo4j cluster. On the Neptune sink, tenant DB creates + are no-ops. + """ + if _is_ingest_database(database): + ingest.create_database(database) + return + + sink_module.get_backend().create_database(database) + + +def drop_database(database: str) -> None: + """Drop a database. Mirrors `create_database` routing.""" + if _is_ingest_database(database): + ingest.drop_database(database) + return + + sink_module.get_backend().drop_database(database) + + +def drop_subgraph(database: str, provider_id: str) -> int: + return sink_module.get_backend().drop_subgraph(database, provider_id) + + +def has_provider_data(database: str, provider_id: str) -> bool: + return sink_module.get_backend().has_provider_data(database, provider_id) + + +def clear_cache(database: str) -> None: + if _is_ingest_database(database): + ingest.clear_cache(database) + return + + sink_module.get_backend().clear_cache(database) + + +# Name helper + + +def get_database_name(entity_id: str | UUID, temporary: bool = False) -> str: + prefix = "tmp-scan" if temporary else "tenant" + return f"db-{prefix}-{str(entity_id).lower()}" diff --git a/api/src/backend/api/attack_paths/ingest/__init__.py b/api/src/backend/api/attack_paths/ingest/__init__.py new file mode 100644 index 0000000000..5833b8b373 --- /dev/null +++ b/api/src/backend/api/attack_paths/ingest/__init__.py @@ -0,0 +1,29 @@ +"""Cartography ingest layer. + +Public surface for the per-scan Neo4j temp database driver. Implementation +lives in `api.attack_paths.ingest.driver`. +""" + +from api.attack_paths.ingest.driver import ( + clear_cache, + close_driver, + create_database, + drop_database, + get_driver, + get_session, + get_uri, + init_driver, + run_cypher, +) + +__all__ = [ + "clear_cache", + "close_driver", + "create_database", + "drop_database", + "get_driver", + "get_session", + "get_uri", + "init_driver", + "run_cypher", +] diff --git a/api/src/backend/api/attack_paths/ingest/driver.py b/api/src/backend/api/attack_paths/ingest/driver.py new file mode 100644 index 0000000000..1b05c721e7 --- /dev/null +++ b/api/src/backend/api/attack_paths/ingest/driver.py @@ -0,0 +1,187 @@ +"""Cartography ingest driver: per-scan throw-away Neo4j database. + +Cartography writes each scan's graph into a throw-away Neo4j database named +`db-tmp-scan-{scan_uuid}`. This is always Neo4j, regardless of the configured +sink: Neptune is single-database and cannot host per-scan throw-away +databases. This module owns the Neo4j driver used for those temp DBs and the +admin ops they need (CREATE / DROP DATABASE). +""" + +import atexit +import logging +import threading +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Any + +import neo4j +import neo4j.exceptions +from api.attack_paths.retryable_session import RetryableSession +from config.env import env +from django.conf import settings + +logging.getLogger("neo4j").setLevel(logging.ERROR) +logging.getLogger("neo4j").propagate = False + +SERVICE_UNAVAILABLE_MAX_RETRIES = env.int( + "ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES", default=3 +) +CONN_ACQUISITION_TIMEOUT = env.int("NEO4J_CONN_ACQUISITION_TIMEOUT", default=15) +# TCP connect timeout, ordered below the acquisition timeout so an unreachable +# host can't pin a worker on a temp-DB op longer than this. +CONNECTION_TIMEOUT = env.int("NEO4J_CONNECTION_TIMEOUT", default=5) +MAX_CONNECTION_LIFETIME = env.int("NEO4J_MAX_CONNECTION_LIFETIME", default=7200) +MAX_CONNECTION_POOL_SIZE = env.int("NEO4J_MAX_CONNECTION_POOL_SIZE", default=50) + +_driver: neo4j.Driver | None = None +_lock = threading.Lock() + + +def _neo4j_config() -> dict: + return settings.DATABASES["neo4j"] + + +def get_uri() -> str: + """Bolt URI for the Neo4j temp (ingest) database. Always Neo4j.""" + config = _neo4j_config() + host = config["HOST"] + port = config["PORT"] + if not host or not port: + raise RuntimeError( + "NEO4J_HOST / NEO4J_PORT must be set to use the attack-paths " + "temp database. Workers require Neo4j env even when the sink is Neptune." + ) + + return f"bolt://{host}:{port}" + + +def init_driver() -> neo4j.Driver: + """Initialize the temp-database Neo4j driver. Idempotent.""" + global _driver + if _driver is not None: + return _driver + + with _lock: + if _driver is None: + config = _neo4j_config() + _driver = neo4j.GraphDatabase.driver( + get_uri(), + auth=(config["USER"], config["PASSWORD"]), + keep_alive=True, + max_connection_lifetime=MAX_CONNECTION_LIFETIME, + connection_timeout=CONNECTION_TIMEOUT, + connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT, + max_connection_pool_size=MAX_CONNECTION_POOL_SIZE, + ) + # Best-effort connectivity check: a Neo4j that is down at boot must + # not crash the worker. The driver reconnects lazily on first use. + try: + _driver.verify_connectivity() + + except Exception: + logging.warning( + "Neo4j temp-database unreachable at init; continuing with a " + "lazily-reconnecting driver", + exc_info=True, + ) + + atexit.register(close_driver) + + return _driver + + +def get_driver() -> neo4j.Driver: + return init_driver() + + +def close_driver() -> None: + global _driver + with _lock: + if _driver is not None: + try: + _driver.close() + finally: + _driver = None + + +@contextmanager +def get_session( + database: str | None = None, + default_access_mode: str | None = None, +) -> Iterator[RetryableSession]: + """Session against the Neo4j temp-database cluster. Used for temp DB sessions + and for admin operations (CREATE / DROP DATABASE) when `database` is None.""" + from api.attack_paths.database import ( + ClientStatementException, + GraphDatabaseQueryException, + WriteQueryNotAllowedException, + ) + + READ_EXCEPTION_CODES = [ + "Neo.ClientError.Statement.AccessMode", + "Neo.ClientError.Procedure.ProcedureNotFound", + ] + CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement." + + session_wrapper: RetryableSession | None = None + try: + session_wrapper = RetryableSession( + session_factory=lambda: get_driver().session( + database=database, default_access_mode=default_access_mode + ), + max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES, + ) + yield session_wrapper + + except neo4j.exceptions.Neo4jError as exc: + if ( + default_access_mode == neo4j.READ_ACCESS + and exc.code + and exc.code in READ_EXCEPTION_CODES + ): + raise WriteQueryNotAllowedException( + message="Read query not allowed", code=READ_EXCEPTION_CODES[0] + ) + + message = exc.message if exc.message is not None else str(exc) + if exc.code and exc.code.startswith(CLIENT_STATEMENT_EXCEPTION_PREFIX): + raise ClientStatementException(message=message, code=exc.code) + raise GraphDatabaseQueryException(message=message, code=exc.code) + + finally: + if session_wrapper is not None: + session_wrapper.close() + + +def create_database(database: str) -> None: + """Create a database on the Neo4j cluster. Used for temp scan DBs.""" + with get_session() as session: + session.run("CREATE DATABASE $database IF NOT EXISTS", {"database": database}) + + +def drop_database(database: str) -> None: + """Drop a database on the Neo4j cluster. Used for temp scan DBs.""" + with get_session() as session: + session.run(f"DROP DATABASE `{database}` IF EXISTS DESTROY DATA") + + +def clear_cache(database: str) -> None: + """Best-effort cache clear for a Neo4j database.""" + from api.attack_paths.database import GraphDatabaseQueryException + + try: + with get_session(database) as session: + session.run("CALL db.clearQueryCaches()") + + except GraphDatabaseQueryException as exc: + logging.warning(f"Failed to clear query cache for database `{database}`: {exc}") + + +def run_cypher( + database: str | None, + cypher: str, + parameters: dict[str, Any] | None = None, +) -> Any: + """Execute Cypher directly without the context manager. Thin helper.""" + with get_session(database) as session: + return session.run(cypher, parameters or {}) diff --git a/api/src/backend/api/attack_paths/queries/aws.py b/api/src/backend/api/attack_paths/queries/aws.py index d9792845c0..fa42854156 100644 --- a/api/src/backend/api/attack_paths/queries/aws.py +++ b/api/src/backend/api/attack_paths/queries/aws.py @@ -6,7 +6,6 @@ from api.attack_paths.queries.types import ( from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL # Custom Attack Path Queries -# -------------------------- AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( id="aws-internet-exposed-ec2-sensitive-s3-access", @@ -22,14 +21,18 @@ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( WHERE ec2.exposed_internet = true AND ipi.toport = 22 - MATCH path_role = (r:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE ANY(x IN stmt.resource WHERE x CONTAINS s3.name) - AND ANY(x IN stmt.action WHERE toLower(x) =~ 's3:(listbucket|getobject).*') + MATCH path_role = (r:AWSRole)-[:POLICY]->(pol:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value CONTAINS s3.name + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) STARTS WITH 's3:listbucket' + OR toLower(act.value) STARTS WITH 's3:getobject' MATCH path_assume_role = (ec2)-[p:STS_ASSUMEROLE_ALLOW*1..9]-(r:AWSRole) OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2) + WITH DISTINCT path_s3, path_ec2, path_role, path_assume_role, internet, can_access WITH collect(path_s3) + collect(path_ec2) + collect(path_role) + collect(path_assume_role) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access UNWIND paths AS p @@ -37,7 +40,7 @@ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -59,7 +62,6 @@ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( # Basic Resource Queries -# ---------------------- AWS_RDS_INSTANCES = AttackPathsQueryDefinition( id="aws-rds-instances", @@ -76,7 +78,7 @@ AWS_RDS_INSTANCES = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -99,7 +101,7 @@ AWS_RDS_UNENCRYPTED_STORAGE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -122,7 +124,7 @@ AWS_S3_ANONYMOUS_ACCESS_BUCKETS = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -136,17 +138,18 @@ AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS = AttackPathsQueryDefinition( description="Find IAM policy statements that allow all actions via '*' within the selected account.", provider="aws", cypher=f""" - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(x IN stmt.action WHERE x = '*') + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(pol:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE act.value = '*' + WITH DISTINCT path WITH collect(path) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]->(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -160,17 +163,18 @@ AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY = AttackPathsQueryDefinition( description="Find IAM policy statements that allow the iam:DeletePolicy action within the selected account.", provider="aws", cypher=f""" - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(x IN stmt.action WHERE x = "iam:DeletePolicy") + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(pol:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE act.value = 'iam:DeletePolicy' + WITH DISTINCT path WITH collect(path) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -184,17 +188,18 @@ AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS = AttackPathsQueryDefinition( description="Find IAM policy statements that allow actions containing 'create' within the selected account.", provider="aws", cypher=f""" - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = "Allow" - AND any(x IN stmt.action WHERE toLower(x) CONTAINS "create") + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(pol:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) CONTAINS 'create' + WITH DISTINCT path WITH collect(path) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -203,7 +208,6 @@ AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS = AttackPathsQueryDefinition( # Network Exposure Queries -# ------------------------ AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition( id="aws-ec2-instances-internet-exposed", @@ -223,7 +227,7 @@ AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition( WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -249,7 +253,7 @@ AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING = AttackPathsQueryDefinition( WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -274,7 +278,7 @@ AWS_CLASSIC_ELB_INTERNET_EXPOSED = AttackPathsQueryDefinition( WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -299,7 +303,7 @@ AWS_ELBV2_INTERNET_EXPOSED = AttackPathsQueryDefinition( WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -327,7 +331,7 @@ AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition( WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -343,7 +347,6 @@ AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition( # Privilege Escalation Queries (based on pathfinding.cloud research) # https://github.com/DataDog/pathfinding.cloud -# ------------------------------------------------------------------- # APPRUNNER-001 AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( @@ -358,31 +361,27 @@ AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find apprunner:CreateService permission - MATCH (principal)--(apprunner_policy:AWSPolicy)--(stmt_apprunner:AWSPolicyStatement) - WHERE stmt_apprunner.effect = 'Allow' - AND any(action IN stmt_apprunner.action WHERE - toLower(action) = 'apprunner:createservice' - OR toLower(action) = 'apprunner:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(apprunner_policy:AWSPolicy)-[:STATEMENT]->(stmt_apprunner:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_apprunner)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['apprunner:*', 'apprunner:createservice'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust App Runner tasks service (can be passed to App Runner) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'tasks.apprunner.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -390,7 +389,7 @@ AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -410,25 +409,23 @@ AWS_APPRUNNER_PRIVESC_UPDATE_SERVICE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with apprunner:UpdateService permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(update_policy:AWSPolicy)--(stmt_update:AWSPolicyStatement) - WHERE stmt_update.effect = 'Allow' - AND any(action IN stmt_update.action WHERE - toLower(action) = 'apprunner:updateservice' - OR toLower(action) = 'apprunner:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(update_policy:AWSPolicy)-[:STATEMENT]->(stmt_update:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_update)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['apprunner:*', 'apprunner:updateservice'] + OR act.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find existing App Runner services with roles attached (potential targets) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'tasks.apprunner.amazonaws.com'}}) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -448,49 +445,41 @@ AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find bedrock-agentcore:CreateCodeInterpreter permission - MATCH (principal)--(bedrock_policy:AWSPolicy)--(stmt_bedrock:AWSPolicyStatement) - WHERE stmt_bedrock.effect = 'Allow' - AND any(action IN stmt_bedrock.action WHERE - toLower(action) = 'bedrock-agentcore:createcodeinterpreter' - OR toLower(action) = 'bedrock-agentcore:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(bedrock_policy:AWSPolicy)-[:STATEMENT]->(stmt_bedrock:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_bedrock)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['bedrock-agentcore:*', 'bedrock-agentcore:createcodeinterpreter'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find bedrock-agentcore:StartCodeInterpreterSession permission - MATCH (principal)--(session_policy:AWSPolicy)--(stmt_session:AWSPolicyStatement) - WHERE stmt_session.effect = 'Allow' - AND any(action IN stmt_session.action WHERE - toLower(action) = 'bedrock-agentcore:startcodeinterpretersession' - OR toLower(action) = 'bedrock-agentcore:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(session_policy:AWSPolicy)-[:STATEMENT]->(stmt_session:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_session)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['bedrock-agentcore:*', 'bedrock-agentcore:startcodeinterpretersession'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find bedrock-agentcore:InvokeCodeInterpreter permission - MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement) - WHERE stmt_invoke.effect = 'Allow' - AND any(action IN stmt_invoke.action WHERE - toLower(action) = 'bedrock-agentcore:invokecodeinterpreter' - OR toLower(action) = 'bedrock-agentcore:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(invoke_policy:AWSPolicy)-[:STATEMENT]->(stmt_invoke:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_invoke)-[:HAS_ACTION]->(act4:AWSPolicyStatementActionItem) + WHERE toLower(act4.value) IN ['bedrock-agentcore:*', 'bedrock-agentcore:invokecodeinterpreter'] + OR act4.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust the Bedrock AgentCore service (can be passed to a code interpreter) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock-agentcore.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -498,7 +487,7 @@ AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -518,34 +507,30 @@ AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with bedrock-agentcore:StartCodeInterpreterSession permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(session_policy:AWSPolicy)--(stmt_session:AWSPolicyStatement) - WHERE stmt_session.effect = 'Allow' - AND any(action IN stmt_session.action WHERE - toLower(action) = 'bedrock-agentcore:startcodeinterpretersession' - OR toLower(action) = 'bedrock-agentcore:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(session_policy:AWSPolicy)-[:STATEMENT]->(stmt_session:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_session)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['bedrock-agentcore:*', 'bedrock-agentcore:startcodeinterpretersession'] + OR act.value = '*' + WITH DISTINCT aws, principal, path_principal // Find bedrock-agentcore:InvokeCodeInterpreter permission - MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement) - WHERE stmt_invoke.effect = 'Allow' - AND any(action IN stmt_invoke.action WHERE - toLower(action) = 'bedrock-agentcore:invokecodeinterpreter' - OR toLower(action) = 'bedrock-agentcore:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(invoke_policy:AWSPolicy)-[:STATEMENT]->(stmt_invoke:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_invoke)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['bedrock-agentcore:*', 'bedrock-agentcore:invokecodeinterpreter'] + OR act2.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find roles that trust the Bedrock AgentCore service (already attached to existing code interpreters) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock-agentcore.amazonaws.com'}}) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -565,31 +550,27 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find cloudformation:CreateStack permission - MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement) - WHERE stmt_cfn.effect = 'Allow' - AND any(action IN stmt_cfn.action WHERE - toLower(action) = 'cloudformation:createstack' - OR toLower(action) = 'cloudformation:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(cfn_policy:AWSPolicy)-[:STATEMENT]->(stmt_cfn:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_cfn)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['cloudformation:*', 'cloudformation:createstack'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust CloudFormation service (can be passed to CloudFormation) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -597,7 +578,7 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -617,25 +598,23 @@ AWS_CLOUDFORMATION_PRIVESC_UPDATE_STACK = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with cloudformation:UpdateStack permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(update_policy:AWSPolicy)--(stmt_update:AWSPolicyStatement) - WHERE stmt_update.effect = 'Allow' - AND any(action IN stmt_update.action WHERE - toLower(action) = 'cloudformation:updatestack' - OR toLower(action) = 'cloudformation:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(update_policy:AWSPolicy)-[:STATEMENT]->(stmt_update:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_update)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['cloudformation:*', 'cloudformation:updatestack'] + OR act.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find roles that trust CloudFormation service (already attached to existing stacks) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -655,40 +634,34 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACKSET = AttackPathsQueryDefinition provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find cloudformation:CreateStackSet permission - MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement) - WHERE stmt_cfn.effect = 'Allow' - AND any(action IN stmt_cfn.action WHERE - toLower(action) = 'cloudformation:createstackset' - OR toLower(action) = 'cloudformation:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(cfn_policy:AWSPolicy)-[:STATEMENT]->(stmt_cfn:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_cfn)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['cloudformation:*', 'cloudformation:createstackset'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find cloudformation:CreateStackInstances permission - MATCH (principal)--(cfn_instances_policy:AWSPolicy)--(stmt_cfn_instances:AWSPolicyStatement) - WHERE stmt_cfn_instances.effect = 'Allow' - AND any(action IN stmt_cfn_instances.action WHERE - toLower(action) = 'cloudformation:createstackinstances' - OR toLower(action) = 'cloudformation:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(cfn_instances_policy:AWSPolicy)-[:STATEMENT]->(stmt_cfn_instances:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_cfn_instances)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['cloudformation:*', 'cloudformation:createstackinstances'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust CloudFormation service (can be passed as execution role) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -696,7 +669,7 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACKSET = AttackPathsQueryDefinition WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -716,31 +689,27 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_UPDATE_STACKSET = AttackPathsQueryDefinition provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find cloudformation:UpdateStackSet permission - MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement) - WHERE stmt_cfn.effect = 'Allow' - AND any(action IN stmt_cfn.action WHERE - toLower(action) = 'cloudformation:updatestackset' - OR toLower(action) = 'cloudformation:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(cfn_policy:AWSPolicy)-[:STATEMENT]->(stmt_cfn:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_cfn)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['cloudformation:*', 'cloudformation:updatestackset'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust CloudFormation service (can be passed as execution role) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -748,7 +717,7 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_UPDATE_STACKSET = AttackPathsQueryDefinition WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -768,34 +737,30 @@ AWS_CLOUDFORMATION_PRIVESC_CHANGESET = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with cloudformation:CreateChangeSet permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) - WHERE stmt_create.effect = 'Allow' - AND any(action IN stmt_create.action WHERE - toLower(action) = 'cloudformation:createchangeset' - OR toLower(action) = 'cloudformation:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_create)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['cloudformation:*', 'cloudformation:createchangeset'] + OR act.value = '*' + WITH DISTINCT aws, principal, path_principal // Find cloudformation:ExecuteChangeSet permission - MATCH (principal)--(exec_policy:AWSPolicy)--(stmt_exec:AWSPolicyStatement) - WHERE stmt_exec.effect = 'Allow' - AND any(action IN stmt_exec.action WHERE - toLower(action) = 'cloudformation:executechangeset' - OR toLower(action) = 'cloudformation:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(exec_policy:AWSPolicy)-[:STATEMENT]->(stmt_exec:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_exec)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['cloudformation:*', 'cloudformation:executechangeset'] + OR act2.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find roles that trust CloudFormation service (already attached to existing stacks) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -815,40 +780,34 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find codebuild:CreateProject permission - MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) - WHERE stmt_create.effect = 'Allow' - AND any(action IN stmt_create.action WHERE - toLower(action) = 'codebuild:createproject' - OR toLower(action) = 'codebuild:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_create)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['codebuild:*', 'codebuild:createproject'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find codebuild:StartBuild permission - MATCH (principal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement) - WHERE stmt_build.effect = 'Allow' - AND any(action IN stmt_build.action WHERE - toLower(action) = 'codebuild:startbuild' - OR toLower(action) = 'codebuild:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(build_policy:AWSPolicy)-[:STATEMENT]->(stmt_build:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_build)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['codebuild:*', 'codebuild:startbuild'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust CodeBuild service (can be passed to CodeBuild) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -856,7 +815,7 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -876,25 +835,23 @@ AWS_CODEBUILD_PRIVESC_START_BUILD = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with codebuild:StartBuild permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement) - WHERE stmt_build.effect = 'Allow' - AND any(action IN stmt_build.action WHERE - toLower(action) = 'codebuild:startbuild' - OR toLower(action) = 'codebuild:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(build_policy:AWSPolicy)-[:STATEMENT]->(stmt_build:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_build)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['codebuild:*', 'codebuild:startbuild'] + OR act.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find roles that trust CodeBuild service (already attached to existing projects) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -914,25 +871,23 @@ AWS_CODEBUILD_PRIVESC_START_BUILD_BATCH = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with codebuild:StartBuildBatch permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement) - WHERE stmt_build.effect = 'Allow' - AND any(action IN stmt_build.action WHERE - toLower(action) = 'codebuild:startbuildbatch' - OR toLower(action) = 'codebuild:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(build_policy:AWSPolicy)-[:STATEMENT]->(stmt_build:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_build)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['codebuild:*', 'codebuild:startbuildbatch'] + OR act.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find roles that trust CodeBuild service (already attached to existing projects) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -952,40 +907,34 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH = AttackPathsQueryDefinition provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find codebuild:CreateProject permission - MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) - WHERE stmt_create.effect = 'Allow' - AND any(action IN stmt_create.action WHERE - toLower(action) = 'codebuild:createproject' - OR toLower(action) = 'codebuild:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_create)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['codebuild:*', 'codebuild:createproject'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find codebuild:StartBuildBatch permission - MATCH (principal)--(batch_policy:AWSPolicy)--(stmt_batch:AWSPolicyStatement) - WHERE stmt_batch.effect = 'Allow' - AND any(action IN stmt_batch.action WHERE - toLower(action) = 'codebuild:startbuildbatch' - OR toLower(action) = 'codebuild:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(batch_policy:AWSPolicy)-[:STATEMENT]->(stmt_batch:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_batch)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['codebuild:*', 'codebuild:startbuildbatch'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust CodeBuild service (can be passed to CodeBuild) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -993,7 +942,7 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH = AttackPathsQueryDefinition WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1013,50 +962,42 @@ AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find datapipeline:CreatePipeline permission - MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) - WHERE stmt_create.effect = 'Allow' - AND any(action IN stmt_create.action WHERE - toLower(action) = 'datapipeline:createpipeline' - OR toLower(action) = 'datapipeline:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_create)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['datapipeline:*', 'datapipeline:createpipeline'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find datapipeline:PutPipelineDefinition permission - MATCH (principal)--(put_policy:AWSPolicy)--(stmt_put:AWSPolicyStatement) - WHERE stmt_put.effect = 'Allow' - AND any(action IN stmt_put.action WHERE - toLower(action) = 'datapipeline:putpipelinedefinition' - OR toLower(action) = 'datapipeline:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(put_policy:AWSPolicy)-[:STATEMENT]->(stmt_put:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_put)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['datapipeline:*', 'datapipeline:putpipelinedefinition'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find datapipeline:ActivatePipeline permission - MATCH (principal)--(activate_policy:AWSPolicy)--(stmt_activate:AWSPolicyStatement) - WHERE stmt_activate.effect = 'Allow' - AND any(action IN stmt_activate.action WHERE - toLower(action) = 'datapipeline:activatepipeline' - OR toLower(action) = 'datapipeline:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(activate_policy:AWSPolicy)-[:STATEMENT]->(stmt_activate:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_activate)-[:HAS_ACTION]->(act4:AWSPolicyStatementActionItem) + WHERE toLower(act4.value) IN ['datapipeline:*', 'datapipeline:activatepipeline'] + OR act4.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust Data Pipeline or EMR service (can be passed to DataPipeline) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(trusted_principal:AWSPrincipal) WHERE trusted_principal.arn IN ['datapipeline.amazonaws.com', 'elasticmapreduce.amazonaws.com'] - AND any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -1064,7 +1005,7 @@ AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1084,31 +1025,27 @@ AWS_EC2_PRIVESC_PASSROLE_IAM = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find ec2:RunInstances permission - MATCH (principal)--(ec2_policy:AWSPolicy)--(stmt_ec2:AWSPolicyStatement) - WHERE stmt_ec2.effect = 'Allow' - AND any(action IN stmt_ec2.action WHERE - toLower(action) = 'ec2:runinstances' - OR toLower(action) = 'ec2:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(ec2_policy:AWSPolicy)-[:STATEMENT]->(stmt_ec2:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_ec2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['ec2:*', 'ec2:runinstances'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust EC2 service (can be passed to EC2) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -1116,7 +1053,7 @@ AWS_EC2_PRIVESC_PASSROLE_IAM = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1136,43 +1073,37 @@ AWS_EC2_PRIVESC_MODIFY_INSTANCE_ATTRIBUTE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with ec2:ModifyInstanceAttribute permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(modify_policy:AWSPolicy)--(stmt_modify:AWSPolicyStatement) - WHERE stmt_modify.effect = 'Allow' - AND any(action IN stmt_modify.action WHERE - toLower(action) = 'ec2:modifyinstanceattribute' - OR toLower(action) = 'ec2:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(modify_policy:AWSPolicy)-[:STATEMENT]->(stmt_modify:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_modify)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['ec2:*', 'ec2:modifyinstanceattribute'] + OR act.value = '*' + WITH DISTINCT aws, principal, path_principal // Find ec2:StopInstances permission (can be same or different policy) - MATCH (principal)--(stop_policy:AWSPolicy)--(stmt_stop:AWSPolicyStatement) - WHERE stmt_stop.effect = 'Allow' - AND any(action IN stmt_stop.action WHERE - toLower(action) = 'ec2:stopinstances' - OR toLower(action) = 'ec2:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(stop_policy:AWSPolicy)-[:STATEMENT]->(stmt_stop:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_stop)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['ec2:*', 'ec2:stopinstances'] + OR act2.value = '*' + WITH DISTINCT aws, principal, path_principal // Find ec2:StartInstances permission (can be same or different policy) - MATCH (principal)--(start_policy:AWSPolicy)--(stmt_start:AWSPolicyStatement) - WHERE stmt_start.effect = 'Allow' - AND any(action IN stmt_start.action WHERE - toLower(action) = 'ec2:startinstances' - OR toLower(action) = 'ec2:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(start_policy:AWSPolicy)-[:STATEMENT]->(stmt_start:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_start)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['ec2:*', 'ec2:startinstances'] + OR act3.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find EC2 instances with instance profiles (potential targets) MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1192,31 +1123,27 @@ AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find ec2:RequestSpotInstances permission - MATCH (principal)--(spot_policy:AWSPolicy)--(stmt_spot:AWSPolicyStatement) - WHERE stmt_spot.effect = 'Allow' - AND any(action IN stmt_spot.action WHERE - toLower(action) = 'ec2:requestspotinstances' - OR toLower(action) = 'ec2:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(spot_policy:AWSPolicy)-[:STATEMENT]->(stmt_spot:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_spot)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['ec2:*', 'ec2:requestspotinstances'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust EC2 service (can be passed to EC2 spot instances) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -1224,7 +1151,7 @@ AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1244,34 +1171,30 @@ AWS_EC2_PRIVESC_LAUNCH_TEMPLATE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with ec2:CreateLaunchTemplateVersion permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) - WHERE stmt_create.effect = 'Allow' - AND any(action IN stmt_create.action WHERE - toLower(action) = 'ec2:createlaunchtemplateversion' - OR toLower(action) = 'ec2:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_create)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['ec2:*', 'ec2:createlaunchtemplateversion'] + OR act.value = '*' + WITH DISTINCT aws, principal, path_principal // Find ec2:ModifyLaunchTemplate permission - MATCH (principal)--(modify_policy:AWSPolicy)--(stmt_modify:AWSPolicyStatement) - WHERE stmt_modify.effect = 'Allow' - AND any(action IN stmt_modify.action WHERE - toLower(action) = 'ec2:modifylaunchtemplate' - OR toLower(action) = 'ec2:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(modify_policy:AWSPolicy)-[:STATEMENT]->(stmt_modify:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_modify)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['ec2:*', 'ec2:modifylaunchtemplate'] + OR act2.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find launch templates in the account (potential targets) MATCH path_target = (aws)--(template:LaunchTemplate) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1291,25 +1214,23 @@ AWS_EC2INSTANCECONNECT_PRIVESC_SEND_SSH_PUBLIC_KEY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with ec2-instance-connect:SendSSHPublicKey permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(connect_policy:AWSPolicy)--(stmt_connect:AWSPolicyStatement) - WHERE stmt_connect.effect = 'Allow' - AND any(action IN stmt_connect.action WHERE - toLower(action) = 'ec2-instance-connect:sendsshpublickey' - OR toLower(action) = 'ec2-instance-connect:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(connect_policy:AWSPolicy)-[:STATEMENT]->(stmt_connect:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_connect)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['ec2-instance-connect:*', 'ec2-instance-connect:sendsshpublickey'] + OR act.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find EC2 instances with attached roles (targets for credential theft via IMDS) MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1328,58 +1249,46 @@ AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( link="https://pathfinding.cloud/paths/ecs-001", ), cypher=f""" - // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:PassRole permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:CreateCluster permission - MATCH (principal)--(cluster_policy:AWSPolicy)--(stmt_cluster:AWSPolicyStatement) - WHERE stmt_cluster.effect = 'Allow' - AND any(action IN stmt_cluster.action WHERE - toLower(action) = 'ecs:createcluster' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:CreateCluster (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a2:AWSPolicyStatementActionItem) + WHERE toLower(a2.value) IN ['ecs:*', 'ecs:createcluster'] + OR a2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:RegisterTaskDefinition permission - MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) - WHERE stmt_taskdef.effect = 'Allow' - AND any(action IN stmt_taskdef.action WHERE - toLower(action) = 'ecs:registertaskdefinition' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:RegisterTaskDefinition (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a3:AWSPolicyStatementActionItem) + WHERE toLower(a3.value) IN ['ecs:*', 'ecs:registertaskdefinition'] + OR a3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:CreateService permission - MATCH (principal)--(service_policy:AWSPolicy)--(stmt_service:AWSPolicyStatement) - WHERE stmt_service.effect = 'Allow' - AND any(action IN stmt_service.action WHERE - toLower(action) = 'ecs:createservice' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:CreateService (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a4:AWSPolicyStatementActionItem) + WHERE toLower(a4.value) IN ['ecs:*', 'ecs:createservice'] + OR a4.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find roles that trust ECS tasks service (can be passed to ECS tasks) + // Target: a role trusting ECS tasks that the passrole resource can target MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1398,58 +1307,48 @@ AWS_ECS_PRIVESC_PASSROLE_RUN_TASK = AttackPathsQueryDefinition( ), provider="aws", cypher=f""" - // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:PassRole permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' - // Find ecs:CreateCluster permission - MATCH (principal)--(cluster_policy:AWSPolicy)--(stmt_cluster:AWSPolicyStatement) - WHERE stmt_cluster.effect = 'Allow' - AND any(action IN stmt_cluster.action WHERE - toLower(action) = 'ecs:createcluster' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Collapse: one row per (passrole chain), independent of how many action items matched + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:RegisterTaskDefinition permission - MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) - WHERE stmt_taskdef.effect = 'Allow' - AND any(action IN stmt_taskdef.action WHERE - toLower(action) = 'ecs:registertaskdefinition' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:CreateCluster exists on the principal -> collapse back to one row + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(s2:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a2:AWSPolicyStatementActionItem) + WHERE toLower(a2.value) IN ['ecs:*', 'ecs:createcluster'] + OR a2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:RunTask permission - MATCH (principal)--(runtask_policy:AWSPolicy)--(stmt_runtask:AWSPolicyStatement) - WHERE stmt_runtask.effect = 'Allow' - AND any(action IN stmt_runtask.action WHERE - toLower(action) = 'ecs:runtask' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:RegisterTaskDefinition exists on the principal + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(s3:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a3:AWSPolicyStatementActionItem) + WHERE toLower(a3.value) IN ['ecs:*', 'ecs:registertaskdefinition'] + OR a3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find roles that trust ECS tasks service (can be passed to ECS tasks) + // Gate: ecs:RunTask exists on the principal + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(s4:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a4:AWSPolicyStatementActionItem) + WHERE toLower(a4.value) IN ['ecs:*', 'ecs:runtask'] + OR a4.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Target: a role that trusts ECS tasks and that the passrole resource can target MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1468,49 +1367,40 @@ AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE_EXISTING_CLUSTER = AttackPathsQueryDefin ), provider="aws", cypher=f""" - // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:PassRole permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:RegisterTaskDefinition permission - MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) - WHERE stmt_taskdef.effect = 'Allow' - AND any(action IN stmt_taskdef.action WHERE - toLower(action) = 'ecs:registertaskdefinition' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:RegisterTaskDefinition (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a2:AWSPolicyStatementActionItem) + WHERE toLower(a2.value) IN ['ecs:*', 'ecs:registertaskdefinition'] + OR a2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:CreateService permission - MATCH (principal)--(service_policy:AWSPolicy)--(stmt_service:AWSPolicyStatement) - WHERE stmt_service.effect = 'Allow' - AND any(action IN stmt_service.action WHERE - toLower(action) = 'ecs:createservice' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:CreateService (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a3:AWSPolicyStatementActionItem) + WHERE toLower(a3.value) IN ['ecs:*', 'ecs:createservice'] + OR a3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find roles that trust ECS tasks service (can be passed to ECS tasks) + // Target: a role trusting ECS tasks that the passrole resource can target MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1529,49 +1419,40 @@ AWS_ECS_PRIVESC_PASSROLE_RUN_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinition( ), provider="aws", cypher=f""" - // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:PassRole permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:RegisterTaskDefinition permission - MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) - WHERE stmt_taskdef.effect = 'Allow' - AND any(action IN stmt_taskdef.action WHERE - toLower(action) = 'ecs:registertaskdefinition' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:RegisterTaskDefinition (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a2:AWSPolicyStatementActionItem) + WHERE toLower(a2.value) IN ['ecs:*', 'ecs:registertaskdefinition'] + OR a2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:RunTask permission - MATCH (principal)--(runtask_policy:AWSPolicy)--(stmt_runtask:AWSPolicyStatement) - WHERE stmt_runtask.effect = 'Allow' - AND any(action IN stmt_runtask.action WHERE - toLower(action) = 'ecs:runtask' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:RunTask (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a3:AWSPolicyStatementActionItem) + WHERE toLower(a3.value) IN ['ecs:*', 'ecs:runtask'] + OR a3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find roles that trust ECS tasks service (can be passed to ECS tasks) + // Target: a role trusting ECS tasks that the passrole resource can target MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1590,49 +1471,40 @@ AWS_ECS_PRIVESC_PASSROLE_START_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinitio ), provider="aws", cypher=f""" - // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:PassRole permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:RegisterTaskDefinition permission - MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) - WHERE stmt_taskdef.effect = 'Allow' - AND any(action IN stmt_taskdef.action WHERE - toLower(action) = 'ecs:registertaskdefinition' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:RegisterTaskDefinition (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a2:AWSPolicyStatementActionItem) + WHERE toLower(a2.value) IN ['ecs:*', 'ecs:registertaskdefinition'] + OR a2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:StartTask permission - MATCH (principal)--(starttask_policy:AWSPolicy)--(stmt_starttask:AWSPolicyStatement) - WHERE stmt_starttask.effect = 'Allow' - AND any(action IN stmt_starttask.action WHERE - toLower(action) = 'ecs:starttask' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:StartTask (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a3:AWSPolicyStatementActionItem) + WHERE toLower(a3.value) IN ['ecs:*', 'ecs:starttask'] + OR a3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find roles that trust ECS tasks service (can be passed to ECS tasks) + // Target: a role trusting ECS tasks that the passrole resource can target MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1651,35 +1523,29 @@ AWS_ECS_PRIVESC_EXECUTE_COMMAND = AttackPathsQueryDefinition( ), provider="aws", cypher=f""" - // Find principals with ecs:ExecuteCommand permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(exec_policy:AWSPolicy)--(stmt_exec:AWSPolicyStatement) - WHERE stmt_exec.effect = 'Allow' - AND any(action IN stmt_exec.action WHERE - toLower(action) = 'ecs:executecommand' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Find principals with ecs:ExecuteCommand permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(exec_policy:AWSPolicy)-[:STATEMENT]->(stmt_exec:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_exec)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['ecs:*', 'ecs:executecommand'] + OR act.value = '*' + WITH DISTINCT aws, principal, path_principal - // Find ecs:DescribeTasks permission (required by AWS CLI to get container runtime ID) - MATCH (principal)--(describe_policy:AWSPolicy)--(stmt_describe:AWSPolicyStatement) - WHERE stmt_describe.effect = 'Allow' - AND any(action IN stmt_describe.action WHERE - toLower(action) = 'ecs:describetasks' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:DescribeTasks (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a2:AWSPolicyStatementActionItem) + WHERE toLower(a2.value) IN ['ecs:*', 'ecs:describetasks'] + OR a2.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths - // Find roles that trust ECS tasks service (already attached to running tasks) + // Target: roles already attached to running tasks (trust ECS tasks service) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1699,31 +1565,27 @@ AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find glue:CreateDevEndpoint permission - MATCH (principal)--(glue_policy:AWSPolicy)--(stmt_glue:AWSPolicyStatement) - WHERE stmt_glue.effect = 'Allow' - AND any(action IN stmt_glue.action WHERE - toLower(action) = 'glue:createdevendpoint' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(glue_policy:AWSPolicy)-[:STATEMENT]->(stmt_glue:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_glue)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['glue:*', 'glue:createdevendpoint'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust Glue service (can be passed to Glue) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -1731,7 +1593,7 @@ AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1751,25 +1613,23 @@ AWS_GLUE_PRIVESC_UPDATE_DEV_ENDPOINT = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with glue:UpdateDevEndpoint permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'glue:updatedevendpoint' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['glue:*', 'glue:updatedevendpoint'] + OR act.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find roles that trust Glue service (already attached to existing dev endpoints) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1789,40 +1649,34 @@ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find glue:CreateJob permission - MATCH (principal)--(createjob_policy:AWSPolicy)--(stmt_createjob:AWSPolicyStatement) - WHERE stmt_createjob.effect = 'Allow' - AND any(action IN stmt_createjob.action WHERE - toLower(action) = 'glue:createjob' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(createjob_policy:AWSPolicy)-[:STATEMENT]->(stmt_createjob:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_createjob)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['glue:*', 'glue:createjob'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find glue:StartJobRun permission - MATCH (principal)--(startjob_policy:AWSPolicy)--(stmt_startjob:AWSPolicyStatement) - WHERE stmt_startjob.effect = 'Allow' - AND any(action IN stmt_startjob.action WHERE - toLower(action) = 'glue:startjobrun' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(startjob_policy:AWSPolicy)-[:STATEMENT]->(stmt_startjob:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_startjob)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['glue:*', 'glue:startjobrun'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust Glue service (can be passed to Glue jobs) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -1830,7 +1684,7 @@ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1850,40 +1704,34 @@ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB_TRIGGER = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find glue:CreateJob permission - MATCH (principal)--(createjob_policy:AWSPolicy)--(stmt_createjob:AWSPolicyStatement) - WHERE stmt_createjob.effect = 'Allow' - AND any(action IN stmt_createjob.action WHERE - toLower(action) = 'glue:createjob' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(createjob_policy:AWSPolicy)-[:STATEMENT]->(stmt_createjob:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_createjob)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['glue:*', 'glue:createjob'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find glue:CreateTrigger permission - MATCH (principal)--(trigger_policy:AWSPolicy)--(stmt_trigger:AWSPolicyStatement) - WHERE stmt_trigger.effect = 'Allow' - AND any(action IN stmt_trigger.action WHERE - toLower(action) = 'glue:createtrigger' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(trigger_policy:AWSPolicy)-[:STATEMENT]->(stmt_trigger:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_trigger)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['glue:*', 'glue:createtrigger'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust Glue service (can be passed to Glue jobs) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -1891,7 +1739,7 @@ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB_TRIGGER = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1911,40 +1759,34 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find glue:UpdateJob permission - MATCH (principal)--(updatejob_policy:AWSPolicy)--(stmt_updatejob:AWSPolicyStatement) - WHERE stmt_updatejob.effect = 'Allow' - AND any(action IN stmt_updatejob.action WHERE - toLower(action) = 'glue:updatejob' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(updatejob_policy:AWSPolicy)-[:STATEMENT]->(stmt_updatejob:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_updatejob)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['glue:*', 'glue:updatejob'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find glue:StartJobRun permission - MATCH (principal)--(startjob_policy:AWSPolicy)--(stmt_startjob:AWSPolicyStatement) - WHERE stmt_startjob.effect = 'Allow' - AND any(action IN stmt_startjob.action WHERE - toLower(action) = 'glue:startjobrun' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(startjob_policy:AWSPolicy)-[:STATEMENT]->(stmt_startjob:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_startjob)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['glue:*', 'glue:startjobrun'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust Glue service (can be passed to Glue jobs) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -1952,7 +1794,7 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1972,40 +1814,34 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find glue:UpdateJob permission - MATCH (principal)--(updatejob_policy:AWSPolicy)--(stmt_updatejob:AWSPolicyStatement) - WHERE stmt_updatejob.effect = 'Allow' - AND any(action IN stmt_updatejob.action WHERE - toLower(action) = 'glue:updatejob' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(updatejob_policy:AWSPolicy)-[:STATEMENT]->(stmt_updatejob:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_updatejob)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['glue:*', 'glue:updatejob'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find glue:CreateTrigger permission - MATCH (principal)--(trigger_policy:AWSPolicy)--(stmt_trigger:AWSPolicyStatement) - WHERE stmt_trigger.effect = 'Allow' - AND any(action IN stmt_trigger.action WHERE - toLower(action) = 'glue:createtrigger' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(trigger_policy:AWSPolicy)-[:STATEMENT]->(stmt_trigger:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_trigger)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['glue:*', 'glue:createtrigger'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust Glue service (can be passed to Glue jobs) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2013,7 +1849,7 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2033,22 +1869,20 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:CreatePolicyVersion permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:createpolicyversion' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:createpolicyversion'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find customer-managed policies attached to the same principal that can be overwritten MATCH path_target = (aws)--(target_policy:AWSPolicy)--(principal) WHERE target_policy.arn CONTAINS $provider_uid - AND any(resource IN stmt.resource WHERE - resource = '*' - OR target_policy.arn CONTAINS resource - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR target_policy.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2056,7 +1890,7 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2076,22 +1910,20 @@ AWS_IAM_PRIVESC_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:CreateAccessKey permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:createaccesskey' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:createaccesskey'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find target users that the principal can create access keys for MATCH path_target = (aws)--(target_user:AWSUser) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_user.arn CONTAINS resource - OR resource CONTAINS target_user.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_user.name + OR target_user.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2099,7 +1931,7 @@ AWS_IAM_PRIVESC_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2118,45 +1950,39 @@ AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( ), provider="aws", cypher=f""" - // Find principals with iam:CreateAccessKey permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:createaccesskey' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:CreateAccessKey permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:createaccesskey'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal - // Find iam:DeleteAccessKey permission - MATCH (principal)--(delete_policy:AWSPolicy)--(stmt_delete:AWSPolicyStatement) - WHERE stmt_delete.effect = 'Allow' - AND any(action IN stmt_delete.action WHERE - toLower(action) = 'iam:deleteaccesskey' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find iam:DeleteAccessKey permission (keep stmt2: its resource is checked) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['iam:*', 'iam:deleteaccesskey'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt, stmt2, path_principal // Find target users that the principal can rotate access keys for MATCH path_target = (aws)--(target_user:AWSUser) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_user.arn CONTAINS resource - OR resource CONTAINS target_user.name - ) - AND any(resource IN stmt_delete.resource WHERE - resource = '*' - OR target_user.arn CONTAINS resource - OR resource CONTAINS target_user.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_user.name + OR target_user.arn CONTAINS res.value + MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) + WHERE res2.value = '*' + OR res2.value CONTAINS target_user.name + OR target_user.arn CONTAINS res2.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2176,22 +2002,20 @@ AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:CreateLoginProfile permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:createloginprofile' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:createloginprofile'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find target users that the principal can create login profiles for MATCH path_target = (aws)--(target_user:AWSUser) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_user.arn CONTAINS resource - OR resource CONTAINS target_user.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_user.name + OR target_user.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2199,7 +2023,7 @@ AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2219,19 +2043,16 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find roles with iam:PutRolePolicy permission scoped to themselves - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:putrolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) - AND any(resource IN stmt.resource WHERE - resource = '*' - OR role.arn CONTAINS resource - OR resource CONTAINS role.name - ) + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:putrolepolicy'] + OR act.value = '*' + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS role.name + OR role.arn CONTAINS res.value + WITH DISTINCT path WITH collect(path) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2239,7 +2060,7 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2259,22 +2080,20 @@ AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:UpdateLoginProfile permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:updateloginprofile' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:updateloginprofile'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find target users that the principal can update login profiles for MATCH path_target = (aws)--(target_user:AWSUser) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_user.arn CONTAINS resource - OR resource CONTAINS target_user.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_user.name + OR target_user.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2282,7 +2101,7 @@ AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2302,19 +2121,16 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find users with iam:PutUserPolicy permission scoped to themselves - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:putuserpolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) - AND any(resource IN stmt.resource WHERE - resource = '*' - OR user.arn CONTAINS resource - OR resource CONTAINS user.name - ) + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:putuserpolicy'] + OR act.value = '*' + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS user.name + OR user.arn CONTAINS res.value + WITH DISTINCT path WITH collect(path) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2322,7 +2138,7 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2342,19 +2158,16 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find users with iam:AttachUserPolicy permission scoped to themselves - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:attachuserpolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) - AND any(resource IN stmt.resource WHERE - resource = '*' - OR user.arn CONTAINS resource - OR resource CONTAINS user.name - ) + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:attachuserpolicy'] + OR act.value = '*' + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS user.name + OR user.arn CONTAINS res.value + WITH DISTINCT path WITH collect(path) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2362,7 +2175,7 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2382,19 +2195,16 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find roles with iam:AttachRolePolicy permission scoped to themselves - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:attachrolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) - AND any(resource IN stmt.resource WHERE - resource = '*' - OR role.arn CONTAINS resource - OR resource CONTAINS role.name - ) + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:attachrolepolicy'] + OR act.value = '*' + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS role.name + OR role.arn CONTAINS res.value + WITH DISTINCT path WITH collect(path) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2402,7 +2212,7 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2422,22 +2232,20 @@ AWS_IAM_PRIVESC_ATTACH_GROUP_POLICY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find users with iam:AttachGroupPolicy permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:attachgrouppolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:attachgrouppolicy'] + OR act.value = '*' + WITH DISTINCT aws, user, stmt, path_principal // Find groups the user is a member of and can attach policies to - MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_group.arn CONTAINS resource - OR resource CONTAINS target_group.name - ) + MATCH path_target = (aws)--(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_group.name + OR target_group.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2445,7 +2253,7 @@ AWS_IAM_PRIVESC_ATTACH_GROUP_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2465,22 +2273,20 @@ AWS_IAM_PRIVESC_PUT_GROUP_POLICY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find users with iam:PutGroupPolicy permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:putgrouppolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:putgrouppolicy'] + OR act.value = '*' + WITH DISTINCT aws, user, stmt, path_principal // Find groups the user is a member of and can put policies on - MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_group.arn CONTAINS resource - OR resource CONTAINS target_group.name - ) + MATCH path_target = (aws)--(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_group.name + OR target_group.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2488,7 +2294,7 @@ AWS_IAM_PRIVESC_PUT_GROUP_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2507,31 +2313,30 @@ AWS_IAM_PRIVESC_UPDATE_ASSUME_ROLE_POLICY = AttackPathsQueryDefinition( ), provider="aws", cypher=f""" - // Find principals with iam:UpdateAssumeRolePolicy permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:updateassumerolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:UpdateAssumeRolePolicy permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:updateassumerolepolicy'] + OR act.value = '*' - // Find target roles whose trust policy can be modified + // Collapse the action-item fan-out: one row per (statement chain), not per matching action + WITH DISTINCT aws, stmt, path_principal + + // Find target roles whose trust policy this statement's resource can target MATCH path_target = (aws)--(target_role:AWSRole) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2551,22 +2356,20 @@ AWS_IAM_PRIVESC_ADD_USER_TO_GROUP = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:AddUserToGroup permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:addusertogroup' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:addusertogroup'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find target groups the principal can add users to - MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_group.arn CONTAINS resource - OR resource CONTAINS target_group.name - ) + MATCH path_target = (aws)--(target_group:AWSGroup) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_group.name + OR target_group.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2574,7 +2377,7 @@ AWS_IAM_PRIVESC_ADD_USER_TO_GROUP = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2594,22 +2397,20 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:AttachRolePolicy permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:attachrolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:attachrolepolicy'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find target roles the principal can assume and attach policies to MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2617,7 +2418,7 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2636,45 +2437,39 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinitio ), provider="aws", cypher=f""" - // Find principals with iam:AttachUserPolicy permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:attachuserpolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:AttachUserPolicy permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:attachuserpolicy'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal - // Find iam:CreateAccessKey permission - MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) - WHERE stmt2.effect = 'Allow' - AND any(action IN stmt2.action WHERE - toLower(action) = 'iam:createaccesskey' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find iam:CreateAccessKey permission (keep stmt2: its resource is checked) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['iam:*', 'iam:createaccesskey'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt, stmt2, path_principal // Find target users the principal can attach policies to and create keys for MATCH path_target = (aws)--(target_user:AWSUser) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_user.arn CONTAINS resource - OR resource CONTAINS target_user.name - ) - AND any(resource IN stmt2.resource WHERE - resource = '*' - OR target_user.arn CONTAINS resource - OR resource CONTAINS target_user.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_user.name + OR target_user.arn CONTAINS res.value + MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) + WHERE res2.value = '*' + OR res2.value CONTAINS target_user.name + OR target_user.arn CONTAINS res2.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2694,23 +2489,21 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_ASSUME_ROLE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:CreatePolicyVersion permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:createpolicyversion' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:createpolicyversion'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find target roles the principal can assume that have customer-managed policies the principal can modify MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) MATCH (target_role)--(target_policy:AWSPolicy) WHERE target_policy.arn CONTAINS $provider_uid - AND any(resource IN stmt.resource WHERE - resource = '*' - OR target_policy.arn CONTAINS resource - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR target_policy.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2718,7 +2511,7 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_ASSUME_ROLE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2738,22 +2531,20 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PutRolePolicy permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:putrolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:putrolepolicy'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find target roles the principal can assume and put inline policies on MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2761,7 +2552,7 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2780,45 +2571,39 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( ), provider="aws", cypher=f""" - // Find principals with iam:PutUserPolicy permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:putuserpolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:PutUserPolicy permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:putuserpolicy'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal - // Find iam:CreateAccessKey permission - MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) - WHERE stmt2.effect = 'Allow' - AND any(action IN stmt2.action WHERE - toLower(action) = 'iam:createaccesskey' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find iam:CreateAccessKey permission (keep stmt2: its resource is checked) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['iam:*', 'iam:createaccesskey'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt, stmt2, path_principal // Find target users the principal can put policies on and create keys for MATCH path_target = (aws)--(target_user:AWSUser) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_user.arn CONTAINS resource - OR resource CONTAINS target_user.name - ) - AND any(resource IN stmt2.resource WHERE - resource = '*' - OR target_user.arn CONTAINS resource - OR resource CONTAINS target_user.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_user.name + OR target_user.arn CONTAINS res.value + MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) + WHERE res2.value = '*' + OR res2.value CONTAINS target_user.name + OR target_user.arn CONTAINS res2.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2837,45 +2622,39 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefiniti ), provider="aws", cypher=f""" - // Find principals with iam:AttachRolePolicy permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:attachrolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:AttachRolePolicy permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:attachrolepolicy'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal - // Find iam:UpdateAssumeRolePolicy permission - MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) - WHERE stmt2.effect = 'Allow' - AND any(action IN stmt2.action WHERE - toLower(action) = 'iam:updateassumerolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find iam:UpdateAssumeRolePolicy permission (keep stmt2: its resource is checked) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['iam:*', 'iam:updateassumerolepolicy'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt, stmt2, path_principal // Find target roles the principal can attach policies to and update trust policy for MATCH path_target = (aws)--(target_role:AWSRole) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) - AND any(resource IN stmt2.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) + WHERE res2.value = '*' + OR res2.value CONTAINS target_role.name + OR target_role.arn CONTAINS res2.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2894,46 +2673,40 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE = AttackPathsQueryDefin ), provider="aws", cypher=f""" - // Find principals with iam:CreatePolicyVersion permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:createpolicyversion' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:CreatePolicyVersion permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:createpolicyversion'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal - // Find iam:UpdateAssumeRolePolicy permission - MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) - WHERE stmt2.effect = 'Allow' - AND any(action IN stmt2.action WHERE - toLower(action) = 'iam:updateassumerolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find iam:UpdateAssumeRolePolicy permission (keep stmt2: its resource is checked) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['iam:*', 'iam:updateassumerolepolicy'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt, stmt2, path_principal // Find target roles with customer-managed policies the principal can modify and update trust policy for MATCH path_target = (aws)--(target_role:AWSRole) - WHERE any(resource IN stmt2.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) - MATCH (target_role)--(target_policy:AWSPolicy) + MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) + WHERE res2.value = '*' + OR res2.value CONTAINS target_role.name + OR target_role.arn CONTAINS res2.value + MATCH (target_role)-[:POLICY]->(target_policy:AWSPolicy) WHERE target_policy.arn CONTAINS $provider_uid - AND any(resource IN stmt.resource WHERE - resource = '*' - OR target_policy.arn CONTAINS resource - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR target_policy.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2952,45 +2725,39 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition( ), provider="aws", cypher=f""" - // Find principals with iam:PutRolePolicy permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:putrolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:PutRolePolicy permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:putrolepolicy'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal - // Find iam:UpdateAssumeRolePolicy permission - MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) - WHERE stmt2.effect = 'Allow' - AND any(action IN stmt2.action WHERE - toLower(action) = 'iam:updateassumerolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find iam:UpdateAssumeRolePolicy permission (keep stmt2: its resource is checked) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['iam:*', 'iam:updateassumerolepolicy'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt, stmt2, path_principal // Find target roles the principal can put inline policies on and update trust policy for MATCH path_target = (aws)--(target_role:AWSRole) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) - AND any(resource IN stmt2.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) + WHERE res2.value = '*' + OR res2.value CONTAINS target_role.name + OR target_role.arn CONTAINS res2.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3010,40 +2777,34 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find lambda:CreateFunction permission - MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) - WHERE stmt_create.effect = 'Allow' - AND any(action IN stmt_create.action WHERE - toLower(action) = 'lambda:createfunction' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_create)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['lambda:*', 'lambda:createfunction'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find lambda:InvokeFunction permission - MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement) - WHERE stmt_invoke.effect = 'Allow' - AND any(action IN stmt_invoke.action WHERE - toLower(action) = 'lambda:invokefunction' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(invoke_policy:AWSPolicy)-[:STATEMENT]->(stmt_invoke:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_invoke)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['lambda:*', 'lambda:invokefunction'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust Lambda service (can be passed to Lambda) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -3051,7 +2812,7 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3071,40 +2832,34 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_EVENT_SOURCE = AttackPathsQueryDefin provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find lambda:CreateFunction permission - MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) - WHERE stmt_create.effect = 'Allow' - AND any(action IN stmt_create.action WHERE - toLower(action) = 'lambda:createfunction' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_create)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['lambda:*', 'lambda:createfunction'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find lambda:CreateEventSourceMapping permission - MATCH (principal)--(event_policy:AWSPolicy)--(stmt_event:AWSPolicyStatement) - WHERE stmt_event.effect = 'Allow' - AND any(action IN stmt_event.action WHERE - toLower(action) = 'lambda:createeventsourcemapping' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(event_policy:AWSPolicy)-[:STATEMENT]->(stmt_event:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_event)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['lambda:*', 'lambda:createeventsourcemapping'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust Lambda service (can be passed to Lambda) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -3112,7 +2867,7 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_EVENT_SOURCE = AttackPathsQueryDefin WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3132,22 +2887,20 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with lambda:UpdateFunctionCode permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'lambda:updatefunctioncode' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['lambda:*', 'lambda:updatefunctioncode'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find existing Lambda functions with execution roles - MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR lambda_fn.arn CONTAINS resource - OR resource CONTAINS lambda_fn.name - ) + MATCH path_target = (aws)--(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS lambda_fn.name + OR lambda_fn.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -3155,7 +2908,7 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3174,45 +2927,39 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_INVOKE = AttackPathsQueryDefinition( ), provider="aws", cypher=f""" - // Find principals with lambda:UpdateFunctionCode permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'lambda:updatefunctioncode' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + // Find principals with lambda:UpdateFunctionCode permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['lambda:*', 'lambda:updatefunctioncode'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal - // Find lambda:InvokeFunction permission - MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) - WHERE stmt2.effect = 'Allow' - AND any(action IN stmt2.action WHERE - toLower(action) = 'lambda:invokefunction' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + // Find lambda:InvokeFunction permission (keep stmt2: its resource is checked) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['lambda:*', 'lambda:invokefunction'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt, stmt2, path_principal // Find existing Lambda functions with execution roles - MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR lambda_fn.arn CONTAINS resource - OR resource CONTAINS lambda_fn.name - ) - AND any(resource IN stmt2.resource WHERE - resource = '*' - OR lambda_fn.arn CONTAINS resource - OR resource CONTAINS lambda_fn.name - ) + MATCH path_target = (aws)--(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS lambda_fn.name + OR lambda_fn.arn CONTAINS res.value + MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) + WHERE res2.value = '*' + OR res2.value CONTAINS lambda_fn.name + OR lambda_fn.arn CONTAINS res2.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3231,45 +2978,39 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_ADD_PERMISSION = AttackPathsQueryDefinit ), provider="aws", cypher=f""" - // Find principals with lambda:UpdateFunctionCode permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'lambda:updatefunctioncode' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + // Find principals with lambda:UpdateFunctionCode permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['lambda:*', 'lambda:updatefunctioncode'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal - // Find lambda:AddPermission permission - MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) - WHERE stmt2.effect = 'Allow' - AND any(action IN stmt2.action WHERE - toLower(action) = 'lambda:addpermission' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + // Find lambda:AddPermission permission (keep stmt2: its resource is checked) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['lambda:*', 'lambda:addpermission'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt, stmt2, path_principal // Find existing Lambda functions with execution roles - MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR lambda_fn.arn CONTAINS resource - OR resource CONTAINS lambda_fn.name - ) - AND any(resource IN stmt2.resource WHERE - resource = '*' - OR lambda_fn.arn CONTAINS resource - OR resource CONTAINS lambda_fn.name - ) + MATCH path_target = (aws)--(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS lambda_fn.name + OR lambda_fn.arn CONTAINS res.value + MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) + WHERE res2.value = '*' + OR res2.value CONTAINS lambda_fn.name + OR lambda_fn.arn CONTAINS res2.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3289,40 +3030,34 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION = AttackPathsQueryDef provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find lambda:CreateFunction permission - MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) - WHERE stmt_create.effect = 'Allow' - AND any(action IN stmt_create.action WHERE - toLower(action) = 'lambda:createfunction' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_create)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['lambda:*', 'lambda:createfunction'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find lambda:AddPermission permission - MATCH (principal)--(perm_policy:AWSPolicy)--(stmt_perm:AWSPolicyStatement) - WHERE stmt_perm.effect = 'Allow' - AND any(action IN stmt_perm.action WHERE - toLower(action) = 'lambda:addpermission' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(perm_policy:AWSPolicy)-[:STATEMENT]->(stmt_perm:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_perm)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['lambda:*', 'lambda:addpermission'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust Lambda service (can be passed to Lambda) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -3330,7 +3065,7 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION = AttackPathsQueryDef WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3350,31 +3085,27 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find sagemaker:CreateNotebookInstance permission - MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement) - WHERE stmt_sm.effect = 'Allow' - AND any(action IN stmt_sm.action WHERE - toLower(action) = 'sagemaker:createnotebookinstance' - OR toLower(action) = 'sagemaker:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(sm_policy:AWSPolicy)-[:STATEMENT]->(stmt_sm:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_sm)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['sagemaker:*', 'sagemaker:createnotebookinstance'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust SageMaker service (can be passed to SageMaker) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -3382,7 +3113,7 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3402,31 +3133,27 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_TRAINING_JOB = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find sagemaker:CreateTrainingJob permission - MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement) - WHERE stmt_sm.effect = 'Allow' - AND any(action IN stmt_sm.action WHERE - toLower(action) = 'sagemaker:createtrainingjob' - OR toLower(action) = 'sagemaker:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(sm_policy:AWSPolicy)-[:STATEMENT]->(stmt_sm:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_sm)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['sagemaker:*', 'sagemaker:createtrainingjob'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust SageMaker service (can be passed to SageMaker) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -3434,7 +3161,7 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_TRAINING_JOB = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3454,31 +3181,27 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_PROCESSING_JOB = AttackPathsQueryDefinitio provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find sagemaker:CreateProcessingJob permission - MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement) - WHERE stmt_sm.effect = 'Allow' - AND any(action IN stmt_sm.action WHERE - toLower(action) = 'sagemaker:createprocessingjob' - OR toLower(action) = 'sagemaker:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(sm_policy:AWSPolicy)-[:STATEMENT]->(stmt_sm:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_sm)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['sagemaker:*', 'sagemaker:createprocessingjob'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust SageMaker service (can be passed to SageMaker) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -3486,7 +3209,7 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_PROCESSING_JOB = AttackPathsQueryDefinitio WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3506,22 +3229,20 @@ AWS_SAGEMAKER_PRIVESC_PRESIGNED_NOTEBOOK_URL = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with sagemaker:CreatePresignedNotebookInstanceUrl permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'sagemaker:createpresignednotebookinstanceurl' - OR toLower(action) = 'sagemaker:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['sagemaker:*', 'sagemaker:createpresignednotebookinstanceurl'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find existing SageMaker notebook instances with execution roles - MATCH path_target = (aws)-[:RESOURCE]->(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR notebook.arn CONTAINS resource - OR resource CONTAINS notebook.notebook_instance_name - ) + MATCH path_target = (aws)--(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS notebook.notebook_instance_name + OR notebook.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -3529,7 +3250,7 @@ AWS_SAGEMAKER_PRIVESC_PRESIGNED_NOTEBOOK_URL = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3548,58 +3269,46 @@ AWS_SAGEMAKER_PRIVESC_LIFECYCLE_CONFIG_NOTEBOOK = AttackPathsQueryDefinition( ), provider="aws", cypher=f""" - // Find principals with sagemaker:CreateNotebookInstanceLifecycleConfig permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'sagemaker:createnotebookinstancelifecycleconfig' - OR toLower(action) = 'sagemaker:*' - OR action = '*' - ) + // Find principals with sagemaker:CreateNotebookInstanceLifecycleConfig permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['sagemaker:*', 'sagemaker:createnotebookinstancelifecycleconfig'] + OR act.value = '*' + WITH DISTINCT aws, principal, path_principal - // Find sagemaker:UpdateNotebookInstance permission - MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) - WHERE stmt2.effect = 'Allow' - AND any(action IN stmt2.action WHERE - toLower(action) = 'sagemaker:updatenotebookinstance' - OR toLower(action) = 'sagemaker:*' - OR action = '*' - ) + // Gate: sagemaker:UpdateNotebookInstance (keep stmt2: its resource is checked) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['sagemaker:*', 'sagemaker:updatenotebookinstance'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt2, path_principal - // Find sagemaker:StopNotebookInstance permission - MATCH (principal)--(policy3:AWSPolicy)--(stmt3:AWSPolicyStatement) - WHERE stmt3.effect = 'Allow' - AND any(action IN stmt3.action WHERE - toLower(action) = 'sagemaker:stopnotebookinstance' - OR toLower(action) = 'sagemaker:*' - OR action = '*' - ) + // Gate: sagemaker:StopNotebookInstance (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['sagemaker:*', 'sagemaker:stopnotebookinstance'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt2, path_principal - // Find sagemaker:StartNotebookInstance permission - MATCH (principal)--(policy4:AWSPolicy)--(stmt4:AWSPolicyStatement) - WHERE stmt4.effect = 'Allow' - AND any(action IN stmt4.action WHERE - toLower(action) = 'sagemaker:startnotebookinstance' - OR toLower(action) = 'sagemaker:*' - OR action = '*' - ) + // Gate: sagemaker:StartNotebookInstance (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act4:AWSPolicyStatementActionItem) + WHERE toLower(act4.value) IN ['sagemaker:*', 'sagemaker:startnotebookinstance'] + OR act4.value = '*' + WITH DISTINCT aws, principal, stmt2, path_principal // Find existing SageMaker notebook instances with execution roles - MATCH path_target = (aws)-[:RESOURCE]->(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole) - WHERE any(resource IN stmt2.resource WHERE - resource = '*' - OR notebook.arn CONTAINS resource - OR resource CONTAINS notebook.notebook_instance_name - ) + MATCH path_target = (aws)--(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole) + MATCH (stmt2)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS notebook.notebook_instance_name + OR notebook.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3619,25 +3328,23 @@ AWS_SSM_PRIVESC_START_SESSION = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with ssm:StartSession permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'ssm:startsession' - OR toLower(action) = 'ssm:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['ssm:*', 'ssm:startsession'] + OR act.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find EC2 instances with attached roles (targets for credential theft via IMDS) MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3657,25 +3364,23 @@ AWS_SSM_PRIVESC_SEND_COMMAND = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with ssm:SendCommand permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'ssm:sendcommand' - OR toLower(action) = 'ssm:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['ssm:*', 'ssm:sendcommand'] + OR act.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find EC2 instances with attached roles (targets for credential theft via IMDS) MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3695,22 +3400,20 @@ AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with sts:AssumeRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'sts:assumerole' - OR toLower(action) = 'sts:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['sts:*', 'sts:assumerole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find target roles the principal can assume (bidirectional trust via Cartography) MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -3718,7 +3421,7 @@ AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3726,7 +3429,6 @@ AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition( ) # AWS Queries List -# ---------------- AWS_QUERIES: list[AttackPathsQueryDefinition] = [ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS, diff --git a/api/src/backend/api/attack_paths/queries/aws_deprecated.py b/api/src/backend/api/attack_paths/queries/aws_deprecated.py new file mode 100644 index 0000000000..b94c329202 --- /dev/null +++ b/api/src/backend/api/attack_paths/queries/aws_deprecated.py @@ -0,0 +1,3819 @@ +# TODO: drop after Neptune cutover +# +# Pre-cutover query catalog for AWS scans whose graph data was written under +# the previous schema, where list-typed policy properties were serialised as +# comma-delimited strings on the parent node. The registry routes scans with +# `is_migrated=False` to this module; all other scans use `aws.py`. Both +# files expose the same query IDs and parameter shapes so the API surface +# stays uniform across the cutover window. This file is deleted, along with +# `AttackPathsScan.is_migrated`, once the legacy data is fully drained. +from api.attack_paths.queries.types import ( + AttackPathsQueryAttribution, + AttackPathsQueryDefinition, + AttackPathsQueryParameterDefinition, +) +from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL + +# Custom Attack Path Queries +# -------------------------- + +AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( + id="aws-internet-exposed-ec2-sensitive-s3-access", + name="Internet-Exposed EC2 with Sensitive S3 Access", + short_description="Find SSH-exposed EC2 instances that can assume roles to read tagged sensitive S3 buckets.", + description="Detect EC2 instances with SSH exposed to the internet that can assume higher-privileged roles to read tagged sensitive S3 buckets despite bucket-level public access blocks.", + provider="aws", + cypher=f""" + MATCH path_s3 = (aws:AWSAccount {{id: $provider_uid}})--(s3:S3Bucket)--(t:AWSTag) + WHERE toLower(t.key) = toLower($tag_key) AND toLower(t.value) = toLower($tag_value) + + MATCH path_ec2 = (aws)--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound) + WHERE ec2.exposed_internet = true + AND ipi.toport = 22 + + MATCH path_role = (r:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE ANY(x IN stmt.resource WHERE x CONTAINS s3.name) + AND ANY(x IN stmt.action WHERE toLower(x) =~ 's3:(listbucket|getobject).*') + + MATCH path_assume_role = (ec2)-[p:STS_ASSUMEROLE_ALLOW*1..9]-(r:AWSRole) + + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2) + + WITH collect(path_s3) + collect(path_ec2) + collect(path_role) + collect(path_assume_role) AS paths, + head(collect(internet)) AS internet, collect(can_access) AS can_access + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[ + AttackPathsQueryParameterDefinition( + name="tag_key", + label="Tag key", + description="Tag key to filter the S3 bucket, e.g. DataClassification.", + placeholder="DataClassification", + ), + AttackPathsQueryParameterDefinition( + name="tag_value", + label="Tag value", + description="Tag value to filter the S3 bucket, e.g. Sensitive.", + placeholder="Sensitive", + ), + ], +) + + +# Basic Resource Queries +# ---------------------- + +AWS_RDS_INSTANCES = AttackPathsQueryDefinition( + id="aws-rds-instances", + name="RDS Instances Inventory", + short_description="List all provisioned RDS database instances in the account.", + description="List the selected AWS account alongside the RDS instances it owns.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(rds:RDSInstance) + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +AWS_RDS_UNENCRYPTED_STORAGE = AttackPathsQueryDefinition( + id="aws-rds-unencrypted-storage", + name="Unencrypted RDS Instances", + short_description="Find RDS instances with storage encryption disabled.", + description="Find RDS instances with storage encryption disabled within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(rds:RDSInstance) + WHERE rds.storage_encrypted = false + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +AWS_S3_ANONYMOUS_ACCESS_BUCKETS = AttackPathsQueryDefinition( + id="aws-s3-anonymous-access-buckets", + name="S3 Buckets with Anonymous Access", + short_description="Find S3 buckets that allow anonymous access.", + description="Find S3 buckets that allow anonymous access within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(s3:S3Bucket) + WHERE s3.anonymous_access = true + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS = AttackPathsQueryDefinition( + id="aws-iam-statements-allow-all-actions", + name="IAM Statements Allowing All Actions", + short_description="Find IAM policy statements that allow all actions via wildcard (*).", + description="Find IAM policy statements that allow all actions via '*' within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(x IN stmt.action WHERE x = '*') + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY = AttackPathsQueryDefinition( + id="aws-iam-statements-allow-delete-policy", + name="IAM Statements Allowing Policy Deletion", + short_description="Find IAM policy statements that allow iam:DeletePolicy.", + description="Find IAM policy statements that allow the iam:DeletePolicy action within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(x IN stmt.action WHERE x = "iam:DeletePolicy") + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS = AttackPathsQueryDefinition( + id="aws-iam-statements-allow-create-actions", + name="IAM Statements Allowing Create Actions", + short_description="Find IAM policy statements that allow any create action.", + description="Find IAM policy statements that allow actions containing 'create' within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = "Allow" + AND any(x IN stmt.action WHERE toLower(x) CONTAINS "create") + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + + +# Network Exposure Queries +# ------------------------ + +AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition( + id="aws-ec2-instances-internet-exposed", + name="Internet-Exposed EC2 Instances", + short_description="Find EC2 instances flagged as exposed to the internet.", + description="Find EC2 instances flagged as exposed to the internet within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance) + WHERE ec2.exposed_internet = true + + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2) + + WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[], +) + +AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING = AttackPathsQueryDefinition( + id="aws-security-groups-open-internet-facing", + name="Open Security Groups on Internet-Facing Resources", + short_description="Find internet-facing resources with security groups allowing inbound from 0.0.0.0/0.", + description="Find internet-facing resources associated with security groups that allow inbound access from '0.0.0.0/0'.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound)--(ir:IpRange) + WHERE ec2.exposed_internet = true + AND ir.range = "0.0.0.0/0" + + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2) + + WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[], +) + +AWS_CLASSIC_ELB_INTERNET_EXPOSED = AttackPathsQueryDefinition( + id="aws-classic-elb-internet-exposed", + name="Internet-Exposed Classic Load Balancers", + short_description="Find Classic Load Balancers exposed to the internet with their listeners.", + description="Find Classic Load Balancers exposed to the internet along with their listeners.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(elb:LoadBalancer)--(listener:ELBListener) + WHERE elb.exposed_internet = true + + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(elb) + + WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[], +) + +AWS_ELBV2_INTERNET_EXPOSED = AttackPathsQueryDefinition( + id="aws-elbv2-internet-exposed", + name="Internet-Exposed ALB/NLB Load Balancers", + short_description="Find ELBv2 (ALB/NLB) load balancers exposed to the internet with their listeners.", + description="Find ELBv2 load balancers exposed to the internet along with their listeners.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(elbv2:LoadBalancerV2)--(listener:ELBV2Listener) + WHERE elbv2.exposed_internet = true + + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(elbv2) + + WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[], +) + +AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition( + id="aws-public-ip-resource-lookup", + name="Resource Lookup by Public IP", + short_description="Find the AWS resource associated with a given public IP address.", + description="Given a public IP address, find the related AWS resource and its adjacent node within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x)-[q]-(y) + WHERE (x:EC2PrivateIp AND x.public_ip = $ip) + OR (x:EC2Instance AND x.publicipaddress = $ip) + OR (x:NetworkInterface AND x.public_ip = $ip) + OR (x:ElasticIPAddress AND x.public_ip = $ip) + + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(x) + + WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[ + AttackPathsQueryParameterDefinition( + name="ip", + label="IP address", + description="Public IP address, e.g. 192.0.2.0.", + placeholder="192.0.2.0", + ), + ], +) + +# Privilege Escalation Queries (based on pathfinding.cloud research) +# https://github.com/DataDog/pathfinding.cloud +# ------------------------------------------------------------------- + +# APPRUNNER-001 +AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( + id="aws-apprunner-privesc-passrole-create-service", + name="App Runner Service Creation with Privileged Role (APPRUNNER-001)", + short_description="Create an App Runner service with a privileged IAM role to gain its permissions.", + description="Detect principals who can pass IAM roles and create App Runner services. This allows creating a service with a privileged role attached, gaining that role's permissions via StartCommand execution, a container web shell, or a malicious apprunner.yaml configuration.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - APPRUNNER-001 - iam:PassRole + apprunner:CreateService", + link="https://pathfinding.cloud/paths/apprunner-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find apprunner:CreateService permission + MATCH (principal)--(apprunner_policy:AWSPolicy)--(stmt_apprunner:AWSPolicyStatement) + WHERE stmt_apprunner.effect = 'Allow' + AND any(action IN stmt_apprunner.action WHERE + toLower(action) = 'apprunner:createservice' + OR toLower(action) = 'apprunner:*' + OR action = '*' + ) + + // Find roles that trust App Runner tasks service (can be passed to App Runner) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'tasks.apprunner.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# APPRUNNER-002 +AWS_APPRUNNER_PRIVESC_UPDATE_SERVICE = AttackPathsQueryDefinition( + id="aws-apprunner-privesc-update-service", + name="App Runner Service Update for Role Access (APPRUNNER-002)", + short_description="Update an existing App Runner service to leverage its already-attached privileged role.", + description="Detect principals who can update existing App Runner services. This allows modifying a service's configuration to execute arbitrary code with the service's already-attached IAM role, without requiring iam:PassRole. Exploitation methods include injecting a malicious StartCommand, updating to a container image with a web shell, or pointing to a repository with a malicious apprunner.yaml file.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - APPRUNNER-002 - apprunner:UpdateService", + link="https://pathfinding.cloud/paths/apprunner-002", + ), + provider="aws", + cypher=f""" + // Find principals with apprunner:UpdateService permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(update_policy:AWSPolicy)--(stmt_update:AWSPolicyStatement) + WHERE stmt_update.effect = 'Allow' + AND any(action IN stmt_update.action WHERE + toLower(action) = 'apprunner:updateservice' + OR toLower(action) = 'apprunner:*' + OR action = '*' + ) + + // Find existing App Runner services with roles attached (potential targets) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'tasks.apprunner.amazonaws.com'}}) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# BEDROCK-001 +AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition( + id="aws-bedrock-privesc-passrole-code-interpreter", + name="Bedrock Code Interpreter with Privileged Role (BEDROCK-001)", + short_description="Create a Bedrock AgentCore Code Interpreter with a privileged role attached.", + description="Detect principals who can pass IAM roles and create Bedrock AgentCore Code Interpreters. This allows creating a code interpreter with a privileged role attached, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - BEDROCK-001 - iam:PassRole + bedrock-agentcore:CreateCodeInterpreter + bedrock-agentcore:StartCodeInterpreterSession + bedrock-agentcore:InvokeCodeInterpreter", + link="https://pathfinding.cloud/paths/bedrock-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find bedrock-agentcore:CreateCodeInterpreter permission + MATCH (principal)--(bedrock_policy:AWSPolicy)--(stmt_bedrock:AWSPolicyStatement) + WHERE stmt_bedrock.effect = 'Allow' + AND any(action IN stmt_bedrock.action WHERE + toLower(action) = 'bedrock-agentcore:createcodeinterpreter' + OR toLower(action) = 'bedrock-agentcore:*' + OR action = '*' + ) + + // Find bedrock-agentcore:StartCodeInterpreterSession permission + MATCH (principal)--(session_policy:AWSPolicy)--(stmt_session:AWSPolicyStatement) + WHERE stmt_session.effect = 'Allow' + AND any(action IN stmt_session.action WHERE + toLower(action) = 'bedrock-agentcore:startcodeinterpretersession' + OR toLower(action) = 'bedrock-agentcore:*' + OR action = '*' + ) + + // Find bedrock-agentcore:InvokeCodeInterpreter permission + MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement) + WHERE stmt_invoke.effect = 'Allow' + AND any(action IN stmt_invoke.action WHERE + toLower(action) = 'bedrock-agentcore:invokecodeinterpreter' + OR toLower(action) = 'bedrock-agentcore:*' + OR action = '*' + ) + + // Find roles that trust the Bedrock AgentCore service (can be passed to a code interpreter) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock-agentcore.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# BEDROCK-002 +AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER = AttackPathsQueryDefinition( + id="aws-bedrock-privesc-invoke-code-interpreter", + name="Bedrock Code Interpreter Session Hijacking (BEDROCK-002)", + short_description="Start a session on an existing Bedrock code interpreter to exfiltrate its privileged role credentials.", + description="Detect principals who can start sessions and invoke code on existing Bedrock AgentCore code interpreters. This allows executing arbitrary Python code within an interpreter that has a privileged role attached, gaining that role's credentials via the MicroVM Metadata Service without requiring iam:PassRole.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - BEDROCK-002 - bedrock-agentcore:StartCodeInterpreterSession + bedrock-agentcore:InvokeCodeInterpreter", + link="https://pathfinding.cloud/paths/bedrock-002", + ), + provider="aws", + cypher=f""" + // Find principals with bedrock-agentcore:StartCodeInterpreterSession permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(session_policy:AWSPolicy)--(stmt_session:AWSPolicyStatement) + WHERE stmt_session.effect = 'Allow' + AND any(action IN stmt_session.action WHERE + toLower(action) = 'bedrock-agentcore:startcodeinterpretersession' + OR toLower(action) = 'bedrock-agentcore:*' + OR action = '*' + ) + + // Find bedrock-agentcore:InvokeCodeInterpreter permission + MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement) + WHERE stmt_invoke.effect = 'Allow' + AND any(action IN stmt_invoke.action WHERE + toLower(action) = 'bedrock-agentcore:invokecodeinterpreter' + OR toLower(action) = 'bedrock-agentcore:*' + OR action = '*' + ) + + // Find roles that trust the Bedrock AgentCore service (already attached to existing code interpreters) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock-agentcore.amazonaws.com'}}) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CLOUDFORMATION-001 +AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK = AttackPathsQueryDefinition( + id="aws-cloudformation-privesc-passrole-create-stack", + name="CloudFormation Stack Creation with Privileged Role (CLOUDFORMATION-001)", + short_description="Create a CloudFormation stack with a privileged role to provision arbitrary AWS resources.", + description="Detect principals who can pass IAM roles and create CloudFormation stacks. This allows launching a stack with a malicious template that executes with the passed role's permissions, enabling creation of resources like IAM users, Lambda functions, or EC2 instances controlled by the attacker.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CLOUDFORMATION-001 - iam:PassRole + cloudformation:CreateStack", + link="https://pathfinding.cloud/paths/cloudformation-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find cloudformation:CreateStack permission + MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement) + WHERE stmt_cfn.effect = 'Allow' + AND any(action IN stmt_cfn.action WHERE + toLower(action) = 'cloudformation:createstack' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find roles that trust CloudFormation service (can be passed to CloudFormation) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CLOUDFORMATION-002 +AWS_CLOUDFORMATION_PRIVESC_UPDATE_STACK = AttackPathsQueryDefinition( + id="aws-cloudformation-privesc-update-stack", + name="CloudFormation Stack Update for Role Access (CLOUDFORMATION-002)", + short_description="Update an existing CloudFormation stack to leverage its already-attached privileged service role.", + description="Detect principals who can update existing CloudFormation stacks. This allows modifying a stack's template to add new resources (such as IAM roles with admin access) that are created with the stack's already-attached service role permissions, without requiring iam:PassRole.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CLOUDFORMATION-002 - cloudformation:UpdateStack", + link="https://pathfinding.cloud/paths/cloudformation-002", + ), + provider="aws", + cypher=f""" + // Find principals with cloudformation:UpdateStack permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(update_policy:AWSPolicy)--(stmt_update:AWSPolicyStatement) + WHERE stmt_update.effect = 'Allow' + AND any(action IN stmt_update.action WHERE + toLower(action) = 'cloudformation:updatestack' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find roles that trust CloudFormation service (already attached to existing stacks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CLOUDFORMATION-003 +AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACKSET = AttackPathsQueryDefinition( + id="aws-cloudformation-privesc-passrole-create-stackset", + name="CloudFormation StackSet Creation with Privileged Role (CLOUDFORMATION-003)", + short_description="Create a CloudFormation StackSet with a privileged execution role to provision arbitrary resources across accounts.", + description="Detect principals who can pass IAM roles, create CloudFormation StackSets, and deploy stack instances. This allows creating a StackSet with a malicious template and a privileged execution role, then deploying instances that create resources (such as IAM roles with admin access) using that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CLOUDFORMATION-003 - iam:PassRole + cloudformation:CreateStackSet + cloudformation:CreateStackInstances", + link="https://pathfinding.cloud/paths/cloudformation-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find cloudformation:CreateStackSet permission + MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement) + WHERE stmt_cfn.effect = 'Allow' + AND any(action IN stmt_cfn.action WHERE + toLower(action) = 'cloudformation:createstackset' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find cloudformation:CreateStackInstances permission + MATCH (principal)--(cfn_instances_policy:AWSPolicy)--(stmt_cfn_instances:AWSPolicyStatement) + WHERE stmt_cfn_instances.effect = 'Allow' + AND any(action IN stmt_cfn_instances.action WHERE + toLower(action) = 'cloudformation:createstackinstances' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find roles that trust CloudFormation service (can be passed as execution role) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CLOUDFORMATION-004 +AWS_CLOUDFORMATION_PRIVESC_PASSROLE_UPDATE_STACKSET = AttackPathsQueryDefinition( + id="aws-cloudformation-privesc-passrole-update-stackset", + name="CloudFormation StackSet Update with Privileged Role (CLOUDFORMATION-004)", + short_description="Update an existing CloudFormation StackSet to inject malicious resources using a privileged execution role.", + description="Detect principals who can pass IAM roles and update CloudFormation StackSets. This allows modifying an existing StackSet's template to add resources (such as IAM roles with admin access) that are provisioned by the StackSet's privileged execution role across target accounts.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CLOUDFORMATION-004 - iam:PassRole + cloudformation:UpdateStackSet", + link="https://pathfinding.cloud/paths/cloudformation-004", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find cloudformation:UpdateStackSet permission + MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement) + WHERE stmt_cfn.effect = 'Allow' + AND any(action IN stmt_cfn.action WHERE + toLower(action) = 'cloudformation:updatestackset' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find roles that trust CloudFormation service (can be passed as execution role) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CLOUDFORMATION-005 +AWS_CLOUDFORMATION_PRIVESC_CHANGESET = AttackPathsQueryDefinition( + id="aws-cloudformation-privesc-changeset", + name="CloudFormation Change Set Privilege Escalation (CLOUDFORMATION-005)", + short_description="Create and execute a change set on an existing stack to leverage its privileged service role.", + description="Detect principals who can create and execute CloudFormation change sets. This allows modifying an existing stack's template through a staged change set, inheriting the stack's already-attached service role permissions to provision arbitrary resources without requiring iam:PassRole.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CLOUDFORMATION-005 - cloudformation:CreateChangeSet + cloudformation:ExecuteChangeSet", + link="https://pathfinding.cloud/paths/cloudformation-005", + ), + provider="aws", + cypher=f""" + // Find principals with cloudformation:CreateChangeSet permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'cloudformation:createchangeset' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find cloudformation:ExecuteChangeSet permission + MATCH (principal)--(exec_policy:AWSPolicy)--(stmt_exec:AWSPolicyStatement) + WHERE stmt_exec.effect = 'Allow' + AND any(action IN stmt_exec.action WHERE + toLower(action) = 'cloudformation:executechangeset' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find roles that trust CloudFormation service (already attached to existing stacks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CODEBUILD-001 +AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT = AttackPathsQueryDefinition( + id="aws-codebuild-privesc-passrole-create-project", + name="CodeBuild Project Creation with Privileged Role (CODEBUILD-001)", + short_description="Create a CodeBuild project with a privileged role to execute arbitrary code via a malicious buildspec.", + description="Detect principals who can pass IAM roles, create CodeBuild projects, and start builds. This allows creating a project with a privileged role attached and executing arbitrary code through a malicious buildspec, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CODEBUILD-001 - iam:PassRole + codebuild:CreateProject + codebuild:StartBuild", + link="https://pathfinding.cloud/paths/codebuild-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find codebuild:CreateProject permission + MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'codebuild:createproject' + OR toLower(action) = 'codebuild:*' + OR action = '*' + ) + + // Find codebuild:StartBuild permission + MATCH (principal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement) + WHERE stmt_build.effect = 'Allow' + AND any(action IN stmt_build.action WHERE + toLower(action) = 'codebuild:startbuild' + OR toLower(action) = 'codebuild:*' + OR action = '*' + ) + + // Find roles that trust CodeBuild service (can be passed to CodeBuild) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CODEBUILD-002 +AWS_CODEBUILD_PRIVESC_START_BUILD = AttackPathsQueryDefinition( + id="aws-codebuild-privesc-start-build", + name="CodeBuild Buildspec Override for Role Access (CODEBUILD-002)", + short_description="Start a build on an existing CodeBuild project with a buildspec override to execute code with its privileged role.", + description="Detect principals who can start builds on existing CodeBuild projects. This allows overriding the buildspec with malicious commands that execute with the project's already-attached service role permissions, without requiring iam:PassRole or codebuild:CreateProject.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CODEBUILD-002 - codebuild:StartBuild", + link="https://pathfinding.cloud/paths/codebuild-002", + ), + provider="aws", + cypher=f""" + // Find principals with codebuild:StartBuild permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement) + WHERE stmt_build.effect = 'Allow' + AND any(action IN stmt_build.action WHERE + toLower(action) = 'codebuild:startbuild' + OR toLower(action) = 'codebuild:*' + OR action = '*' + ) + + // Find roles that trust CodeBuild service (already attached to existing projects) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CODEBUILD-003 +AWS_CODEBUILD_PRIVESC_START_BUILD_BATCH = AttackPathsQueryDefinition( + id="aws-codebuild-privesc-start-build-batch", + name="CodeBuild Batch Buildspec Override for Role Access (CODEBUILD-003)", + short_description="Start a batch build on an existing CodeBuild project with a buildspec override to execute code with its privileged role.", + description="Detect principals who can start batch builds on existing CodeBuild projects. This allows overriding the buildspec with malicious commands that execute with the project's already-attached service role permissions, without requiring iam:PassRole or codebuild:CreateProject.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CODEBUILD-003 - codebuild:StartBuildBatch", + link="https://pathfinding.cloud/paths/codebuild-003", + ), + provider="aws", + cypher=f""" + // Find principals with codebuild:StartBuildBatch permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement) + WHERE stmt_build.effect = 'Allow' + AND any(action IN stmt_build.action WHERE + toLower(action) = 'codebuild:startbuildbatch' + OR toLower(action) = 'codebuild:*' + OR action = '*' + ) + + // Find roles that trust CodeBuild service (already attached to existing projects) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CODEBUILD-004 +AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH = AttackPathsQueryDefinition( + id="aws-codebuild-privesc-passrole-create-project-batch", + name="CodeBuild Batch Project Creation with Privileged Role (CODEBUILD-004)", + short_description="Create a CodeBuild project configured for batch builds with a privileged role to execute arbitrary code via a malicious buildspec.", + description="Detect principals who can pass IAM roles, create CodeBuild projects, and start batch builds. This allows creating a project with a privileged role attached and executing arbitrary code through a malicious batch buildspec, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CODEBUILD-004 - iam:PassRole + codebuild:CreateProject + codebuild:StartBuildBatch", + link="https://pathfinding.cloud/paths/codebuild-004", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find codebuild:CreateProject permission + MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'codebuild:createproject' + OR toLower(action) = 'codebuild:*' + OR action = '*' + ) + + // Find codebuild:StartBuildBatch permission + MATCH (principal)--(batch_policy:AWSPolicy)--(stmt_batch:AWSPolicyStatement) + WHERE stmt_batch.effect = 'Allow' + AND any(action IN stmt_batch.action WHERE + toLower(action) = 'codebuild:startbuildbatch' + OR toLower(action) = 'codebuild:*' + OR action = '*' + ) + + // Find roles that trust CodeBuild service (can be passed to CodeBuild) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# DATAPIPELINE-001 +AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE = AttackPathsQueryDefinition( + id="aws-datapipeline-privesc-passrole-create-pipeline", + name="Data Pipeline Creation with Privileged Role (DATAPIPELINE-001)", + short_description="Create a Data Pipeline with a privileged role to execute arbitrary commands on provisioned infrastructure.", + description="Detect principals who can pass IAM roles, create Data Pipelines, define pipeline objects, and activate them. This allows creating a pipeline with a privileged role attached and executing arbitrary commands on the provisioned EC2 instances or EMR clusters, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - DATAPIPELINE-001 - iam:PassRole + datapipeline:CreatePipeline + datapipeline:PutPipelineDefinition + datapipeline:ActivatePipeline", + link="https://pathfinding.cloud/paths/datapipeline-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find datapipeline:CreatePipeline permission + MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'datapipeline:createpipeline' + OR toLower(action) = 'datapipeline:*' + OR action = '*' + ) + + // Find datapipeline:PutPipelineDefinition permission + MATCH (principal)--(put_policy:AWSPolicy)--(stmt_put:AWSPolicyStatement) + WHERE stmt_put.effect = 'Allow' + AND any(action IN stmt_put.action WHERE + toLower(action) = 'datapipeline:putpipelinedefinition' + OR toLower(action) = 'datapipeline:*' + OR action = '*' + ) + + // Find datapipeline:ActivatePipeline permission + MATCH (principal)--(activate_policy:AWSPolicy)--(stmt_activate:AWSPolicyStatement) + WHERE stmt_activate.effect = 'Allow' + AND any(action IN stmt_activate.action WHERE + toLower(action) = 'datapipeline:activatepipeline' + OR toLower(action) = 'datapipeline:*' + OR action = '*' + ) + + // Find roles that trust Data Pipeline or EMR service (can be passed to DataPipeline) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(trusted_principal:AWSPrincipal) + WHERE trusted_principal.arn IN ['datapipeline.amazonaws.com', 'elasticmapreduce.amazonaws.com'] + AND any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EC2-001 +AWS_EC2_PRIVESC_PASSROLE_IAM = AttackPathsQueryDefinition( + id="aws-ec2-privesc-passrole-iam", + name="EC2 Instance Launch with Privileged Role (EC2-001)", + short_description="Launch EC2 instances with privileged IAM roles to gain their permissions via IMDS.", + description="Detect principals who can launch EC2 instances with privileged IAM roles attached. This allows gaining the permissions of the passed role by accessing the EC2 instance metadata service.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2-001 - iam:PassRole + ec2:RunInstances", + link="https://pathfinding.cloud/paths/ec2-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ec2:RunInstances permission + MATCH (principal)--(ec2_policy:AWSPolicy)--(stmt_ec2:AWSPolicyStatement) + WHERE stmt_ec2.effect = 'Allow' + AND any(action IN stmt_ec2.action WHERE + toLower(action) = 'ec2:runinstances' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find roles that trust EC2 service (can be passed to EC2) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EC2-002 +AWS_EC2_PRIVESC_MODIFY_INSTANCE_ATTRIBUTE = AttackPathsQueryDefinition( + id="aws-ec2-privesc-modify-instance-attribute", + name="EC2 Role Hijacking via UserData Injection (EC2-002)", + short_description="Inject malicious scripts into EC2 instance userData to gain the attached role's permissions.", + description="Detect principals who can modify EC2 instance userData, stop, and start instances. This allows injecting malicious scripts that execute on instance restart, gaining the permissions of the instance's attached IAM role.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2-002 - ec2:ModifyInstanceAttribute + ec2:StopInstances + ec2:StartInstances", + link="https://pathfinding.cloud/paths/ec2-002", + ), + provider="aws", + cypher=f""" + // Find principals with ec2:ModifyInstanceAttribute permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(modify_policy:AWSPolicy)--(stmt_modify:AWSPolicyStatement) + WHERE stmt_modify.effect = 'Allow' + AND any(action IN stmt_modify.action WHERE + toLower(action) = 'ec2:modifyinstanceattribute' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find ec2:StopInstances permission (can be same or different policy) + MATCH (principal)--(stop_policy:AWSPolicy)--(stmt_stop:AWSPolicyStatement) + WHERE stmt_stop.effect = 'Allow' + AND any(action IN stmt_stop.action WHERE + toLower(action) = 'ec2:stopinstances' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find ec2:StartInstances permission (can be same or different policy) + MATCH (principal)--(start_policy:AWSPolicy)--(stmt_start:AWSPolicyStatement) + WHERE stmt_start.effect = 'Allow' + AND any(action IN stmt_start.action WHERE + toLower(action) = 'ec2:startinstances' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find EC2 instances with instance profiles (potential targets) + MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EC2-003 +AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES = AttackPathsQueryDefinition( + id="aws-ec2-privesc-passrole-spot-instances", + name="Spot Instance Launch with Privileged Role (EC2-003)", + short_description="Launch EC2 Spot Instances with privileged IAM roles to gain their permissions via IMDS.", + description="Detect principals who can pass IAM roles and request EC2 Spot Instances. This allows launching a spot instance with a privileged role attached, gaining that role's permissions via the instance metadata service.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2-003 - iam:PassRole + ec2:RequestSpotInstances", + link="https://pathfinding.cloud/paths/ec2-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ec2:RequestSpotInstances permission + MATCH (principal)--(spot_policy:AWSPolicy)--(stmt_spot:AWSPolicyStatement) + WHERE stmt_spot.effect = 'Allow' + AND any(action IN stmt_spot.action WHERE + toLower(action) = 'ec2:requestspotinstances' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find roles that trust EC2 service (can be passed to EC2 spot instances) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EC2-004 +AWS_EC2_PRIVESC_LAUNCH_TEMPLATE = AttackPathsQueryDefinition( + id="aws-ec2-privesc-launch-template", + name="Launch Template Poisoning for Role Access (EC2-004)", + short_description="Inject malicious userData into launch templates that reference privileged roles, no PassRole needed.", + description="Detect principals who can create new launch template versions and modify launch templates. This allows injecting malicious user data into existing templates that already reference privileged IAM roles, without requiring iam:PassRole permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2-004 - ec2:CreateLaunchTemplateVersion + ec2:ModifyLaunchTemplate", + link="https://pathfinding.cloud/paths/ec2-004", + ), + provider="aws", + cypher=f""" + // Find principals with ec2:CreateLaunchTemplateVersion permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'ec2:createlaunchtemplateversion' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find ec2:ModifyLaunchTemplate permission + MATCH (principal)--(modify_policy:AWSPolicy)--(stmt_modify:AWSPolicyStatement) + WHERE stmt_modify.effect = 'Allow' + AND any(action IN stmt_modify.action WHERE + toLower(action) = 'ec2:modifylaunchtemplate' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find launch templates in the account (potential targets) + MATCH path_target = (aws)--(template:LaunchTemplate) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EC2INSTANCECONNECT-003 +AWS_EC2INSTANCECONNECT_PRIVESC_SEND_SSH_PUBLIC_KEY = AttackPathsQueryDefinition( + id="aws-ec2instanceconnect-privesc-send-ssh-public-key", + name="EC2 Instance Connect SSH Access for Role Credentials (EC2INSTANCECONNECT-003)", + short_description="Push a temporary SSH key to an EC2 instance via Instance Connect to access its attached role credentials through IMDS.", + description="Detect principals who can send SSH public keys via EC2 Instance Connect. This allows establishing an SSH session on a running EC2 instance and retrieving the attached IAM role's temporary credentials from the Instance Metadata Service (IMDS), gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2INSTANCECONNECT-003 - ec2-instance-connect:SendSSHPublicKey", + link="https://pathfinding.cloud/paths/ec2instanceconnect-003", + ), + provider="aws", + cypher=f""" + // Find principals with ec2-instance-connect:SendSSHPublicKey permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(connect_policy:AWSPolicy)--(stmt_connect:AWSPolicyStatement) + WHERE stmt_connect.effect = 'Allow' + AND any(action IN stmt_connect.action WHERE + toLower(action) = 'ec2-instance-connect:sendsshpublickey' + OR toLower(action) = 'ec2-instance-connect:*' + OR action = '*' + ) + + // Find EC2 instances with attached roles (targets for credential theft via IMDS) + MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-001 +AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-create-service", + name="ECS Service Creation with Privileged Role (ECS-001 - New Cluster)", + short_description="Create an ECS cluster and service with a privileged Fargate task role to execute arbitrary code.", + description="Detect principals who can pass IAM roles, create ECS clusters, register task definitions, and create services. This allows creating a Fargate task with a privileged role attached, gaining that role's permissions to execute arbitrary code via the container.", + provider="aws", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-001 - iam:PassRole + ecs:CreateCluster + ecs:RegisterTaskDefinition + ecs:CreateService", + link="https://pathfinding.cloud/paths/ecs-001", + ), + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ecs:CreateCluster permission + MATCH (principal)--(cluster_policy:AWSPolicy)--(stmt_cluster:AWSPolicyStatement) + WHERE stmt_cluster.effect = 'Allow' + AND any(action IN stmt_cluster.action WHERE + toLower(action) = 'ecs:createcluster' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:CreateService permission + MATCH (principal)--(service_policy:AWSPolicy)--(stmt_service:AWSPolicyStatement) + WHERE stmt_service.effect = 'Allow' + AND any(action IN stmt_service.action WHERE + toLower(action) = 'ecs:createservice' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-002 +AWS_ECS_PRIVESC_PASSROLE_RUN_TASK = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-run-task", + name="ECS Task Execution with Privileged Role (ECS-002 - New Cluster)", + short_description="Create an ECS cluster and run a one-off Fargate task with a privileged role to execute arbitrary code.", + description="Detect principals who can pass IAM roles, create ECS clusters, register task definitions, and run tasks. This allows creating a Fargate task with a privileged role attached, gaining that role's permissions to execute arbitrary code via the container. Unlike ecs:CreateService, ecs:RunTask executes the task once without creating a persistent service.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-002 - iam:PassRole + ecs:CreateCluster + ecs:RegisterTaskDefinition + ecs:RunTask", + link="https://pathfinding.cloud/paths/ecs-002", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ecs:CreateCluster permission + MATCH (principal)--(cluster_policy:AWSPolicy)--(stmt_cluster:AWSPolicyStatement) + WHERE stmt_cluster.effect = 'Allow' + AND any(action IN stmt_cluster.action WHERE + toLower(action) = 'ecs:createcluster' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:RunTask permission + MATCH (principal)--(runtask_policy:AWSPolicy)--(stmt_runtask:AWSPolicyStatement) + WHERE stmt_runtask.effect = 'Allow' + AND any(action IN stmt_runtask.action WHERE + toLower(action) = 'ecs:runtask' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-003 +AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE_EXISTING_CLUSTER = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-create-service-existing-cluster", + name="ECS Service Creation with Privileged Role (ECS-003 - Existing Cluster)", + short_description="Deploy a Fargate service with a privileged role on an existing ECS cluster.", + description="Detect principals who can pass IAM roles, register ECS task definitions, and create services on existing clusters. Unlike ECS-001, this does not require ecs:CreateCluster since it targets clusters that already exist. The attacker registers a task definition with a privileged role and launches it as a Fargate service, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-003 - iam:PassRole + ecs:RegisterTaskDefinition + ecs:CreateService", + link="https://pathfinding.cloud/paths/ecs-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:CreateService permission + MATCH (principal)--(service_policy:AWSPolicy)--(stmt_service:AWSPolicyStatement) + WHERE stmt_service.effect = 'Allow' + AND any(action IN stmt_service.action WHERE + toLower(action) = 'ecs:createservice' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-004 +AWS_ECS_PRIVESC_PASSROLE_RUN_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-run-task-existing-cluster", + name="ECS Task Execution with Privileged Role (ECS-004 - Existing Cluster)", + short_description="Run a one-off Fargate task with a privileged role on an existing ECS cluster.", + description="Detect principals who can pass IAM roles, register ECS task definitions, and run tasks on existing clusters. Unlike ECS-002, this does not require ecs:CreateCluster since it targets clusters that already exist. The attacker registers a task definition with a privileged role and runs it as a one-off Fargate task, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-004 - iam:PassRole + ecs:RegisterTaskDefinition + ecs:RunTask", + link="https://pathfinding.cloud/paths/ecs-004", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:RunTask permission + MATCH (principal)--(runtask_policy:AWSPolicy)--(stmt_runtask:AWSPolicyStatement) + WHERE stmt_runtask.effect = 'Allow' + AND any(action IN stmt_runtask.action WHERE + toLower(action) = 'ecs:runtask' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-005 +AWS_ECS_PRIVESC_PASSROLE_START_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-start-task-existing-cluster", + name="ECS Task Start with Privileged Role on EC2 (ECS-005 - Existing Cluster)", + short_description="Register a task definition with a privileged role and start it on an EC2 container instance to execute arbitrary code.", + description="Detect principals who can pass IAM roles, register ECS task definitions, and start tasks on existing EC2 container instances. Unlike ecs:RunTask which works with both EC2 and Fargate, ecs:StartTask is specific to EC2 launch types and requires specifying an existing container instance ARN. The attacker registers a task definition with a privileged role and starts it on a container instance, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-005 - iam:PassRole + ecs:RegisterTaskDefinition + ecs:StartTask", + link="https://pathfinding.cloud/paths/ecs-005", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:StartTask permission + MATCH (principal)--(starttask_policy:AWSPolicy)--(stmt_starttask:AWSPolicyStatement) + WHERE stmt_starttask.effect = 'Allow' + AND any(action IN stmt_starttask.action WHERE + toLower(action) = 'ecs:starttask' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-006 +AWS_ECS_PRIVESC_EXECUTE_COMMAND = AttackPathsQueryDefinition( + id="aws-ecs-privesc-execute-command", + name="ECS Exec Container Hijacking for Role Credentials (ECS-006)", + short_description="Shell into a running ECS container via ECS Exec to steal the attached task role's credentials.", + description="Detect principals who can execute commands in running ECS containers and describe tasks. This allows establishing an interactive shell session in a container where ECS Exec is enabled, then retrieving the task role's temporary credentials from the container metadata service, without requiring iam:PassRole.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-006 - ecs:ExecuteCommand + ecs:DescribeTasks", + link="https://pathfinding.cloud/paths/ecs-006", + ), + provider="aws", + cypher=f""" + // Find principals with ecs:ExecuteCommand permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(exec_policy:AWSPolicy)--(stmt_exec:AWSPolicyStatement) + WHERE stmt_exec.effect = 'Allow' + AND any(action IN stmt_exec.action WHERE + toLower(action) = 'ecs:executecommand' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:DescribeTasks permission (required by AWS CLI to get container runtime ID) + MATCH (principal)--(describe_policy:AWSPolicy)--(stmt_describe:AWSPolicyStatement) + WHERE stmt_describe.effect = 'Allow' + AND any(action IN stmt_describe.action WHERE + toLower(action) = 'ecs:describetasks' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (already attached to running tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-001 +AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT = AttackPathsQueryDefinition( + id="aws-glue-privesc-passrole-dev-endpoint", + name="Glue Dev Endpoint with Privileged Role (GLUE-001)", + short_description="Create a Glue development endpoint with a privileged role attached to gain its permissions.", + description="Detect principals who can pass IAM roles and create Glue development endpoints. This allows creating a dev endpoint with a privileged role attached, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-001 - iam:PassRole + glue:CreateDevEndpoint", + link="https://pathfinding.cloud/paths/glue-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find glue:CreateDevEndpoint permission + MATCH (principal)--(glue_policy:AWSPolicy)--(stmt_glue:AWSPolicyStatement) + WHERE stmt_glue.effect = 'Allow' + AND any(action IN stmt_glue.action WHERE + toLower(action) = 'glue:createdevendpoint' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (can be passed to Glue) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-002 +AWS_GLUE_PRIVESC_UPDATE_DEV_ENDPOINT = AttackPathsQueryDefinition( + id="aws-glue-privesc-update-dev-endpoint", + name="Glue Dev Endpoint SSH Hijacking via Update (GLUE-002)", + short_description="Update an existing Glue development endpoint to inject an SSH public key and access its attached role credentials.", + description="Detect principals who can update Glue development endpoints. This allows adding an attacker-controlled SSH public key to an existing dev endpoint that already has a privileged role attached, then SSHing into it to steal the role's temporary credentials without requiring iam:PassRole.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-002 - glue:UpdateDevEndpoint", + link="https://pathfinding.cloud/paths/glue-002", + ), + provider="aws", + cypher=f""" + // Find principals with glue:UpdateDevEndpoint permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'glue:updatedevendpoint' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (already attached to existing dev endpoints) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-003 +AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB = AttackPathsQueryDefinition( + id="aws-glue-privesc-passrole-create-job", + name="Glue Job Creation with Privileged Role (GLUE-003)", + short_description="Create a Glue job with a privileged role and start it to execute arbitrary code with that role's permissions.", + description="Detect principals who can pass IAM roles, create Glue jobs, and start job runs. This allows creating a Python shell job with a privileged role attached and executing arbitrary code that modifies IAM permissions, a cost-effective alternative to Glue development endpoints.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-003 - iam:PassRole + glue:CreateJob + glue:StartJobRun", + link="https://pathfinding.cloud/paths/glue-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find glue:CreateJob permission + MATCH (principal)--(createjob_policy:AWSPolicy)--(stmt_createjob:AWSPolicyStatement) + WHERE stmt_createjob.effect = 'Allow' + AND any(action IN stmt_createjob.action WHERE + toLower(action) = 'glue:createjob' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find glue:StartJobRun permission + MATCH (principal)--(startjob_policy:AWSPolicy)--(stmt_startjob:AWSPolicyStatement) + WHERE stmt_startjob.effect = 'Allow' + AND any(action IN stmt_startjob.action WHERE + toLower(action) = 'glue:startjobrun' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (can be passed to Glue jobs) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-004 +AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB_TRIGGER = AttackPathsQueryDefinition( + id="aws-glue-privesc-passrole-create-job-trigger", + name="Glue Job Creation with Scheduled Trigger and Privileged Role (GLUE-004)", + short_description="Create a Glue job with a privileged role and a scheduled trigger to persistently execute arbitrary code.", + description="Detect principals who can pass IAM roles, create Glue jobs, and create triggers with automatic activation. Unlike manual execution via StartJobRun, this creates a persistent attack by scheduling the job to run repeatedly, making it harder to detect and remediate.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-004 - iam:PassRole + glue:CreateJob + glue:CreateTrigger", + link="https://pathfinding.cloud/paths/glue-004", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find glue:CreateJob permission + MATCH (principal)--(createjob_policy:AWSPolicy)--(stmt_createjob:AWSPolicyStatement) + WHERE stmt_createjob.effect = 'Allow' + AND any(action IN stmt_createjob.action WHERE + toLower(action) = 'glue:createjob' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find glue:CreateTrigger permission + MATCH (principal)--(trigger_policy:AWSPolicy)--(stmt_trigger:AWSPolicyStatement) + WHERE stmt_trigger.effect = 'Allow' + AND any(action IN stmt_trigger.action WHERE + toLower(action) = 'glue:createtrigger' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (can be passed to Glue jobs) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-005 +AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB = AttackPathsQueryDefinition( + id="aws-glue-privesc-passrole-update-job", + name="Glue Job Hijacking via Update with Privileged Role (GLUE-005)", + short_description="Update an existing Glue job to attach a privileged role and inject malicious code, then start it to gain that role's permissions.", + description="Detect principals who can pass IAM roles, update existing Glue jobs, and start job runs. This allows modifying an existing job's role and script to execute arbitrary code with elevated privileges, a stealthier variant of job creation since it reuses existing infrastructure rather than creating new resources.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-005 - iam:PassRole + glue:UpdateJob + glue:StartJobRun", + link="https://pathfinding.cloud/paths/glue-005", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find glue:UpdateJob permission + MATCH (principal)--(updatejob_policy:AWSPolicy)--(stmt_updatejob:AWSPolicyStatement) + WHERE stmt_updatejob.effect = 'Allow' + AND any(action IN stmt_updatejob.action WHERE + toLower(action) = 'glue:updatejob' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find glue:StartJobRun permission + MATCH (principal)--(startjob_policy:AWSPolicy)--(stmt_startjob:AWSPolicyStatement) + WHERE stmt_startjob.effect = 'Allow' + AND any(action IN stmt_startjob.action WHERE + toLower(action) = 'glue:startjobrun' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (can be passed to Glue jobs) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-006 +AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER = AttackPathsQueryDefinition( + id="aws-glue-privesc-passrole-update-job-trigger", + name="Glue Job Hijacking with Scheduled Trigger and Privileged Role (GLUE-006)", + short_description="Update an existing Glue job to attach a privileged role and inject malicious code, then create a scheduled trigger for persistent automated execution.", + description="Detect principals who can pass IAM roles, update existing Glue jobs, and create triggers with automatic activation. This combines the stealth of modifying existing infrastructure with the persistence of scheduled automation, creating a recurring backdoor that re-executes even after remediation attempts.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-006 - iam:PassRole + glue:UpdateJob + glue:CreateTrigger", + link="https://pathfinding.cloud/paths/glue-006", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find glue:UpdateJob permission + MATCH (principal)--(updatejob_policy:AWSPolicy)--(stmt_updatejob:AWSPolicyStatement) + WHERE stmt_updatejob.effect = 'Allow' + AND any(action IN stmt_updatejob.action WHERE + toLower(action) = 'glue:updatejob' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find glue:CreateTrigger permission + MATCH (principal)--(trigger_policy:AWSPolicy)--(stmt_trigger:AWSPolicyStatement) + WHERE stmt_trigger.effect = 'Allow' + AND any(action IN stmt_trigger.action WHERE + toLower(action) = 'glue:createtrigger' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (can be passed to Glue jobs) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-001 +AWS_IAM_PRIVESC_CREATE_POLICY_VERSION = AttackPathsQueryDefinition( + id="aws-iam-privesc-create-policy-version", + name="Policy Version Override for Self-Escalation (IAM-001)", + short_description="Create a new version of an attached policy with administrative permissions, instantly escalating the principal's own privileges.", + description="Detect principals who can create new policy versions. If a customer-managed policy is already attached to a principal and that principal has iam:CreatePolicyVersion on that policy, they can replace its contents with a fully permissive policy and set it as the default, gaining immediate administrative access.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-001 - iam:CreatePolicyVersion", + link="https://pathfinding.cloud/paths/iam-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:CreatePolicyVersion permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:createpolicyversion' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find customer-managed policies attached to the same principal that can be overwritten + MATCH path_target = (aws)--(target_policy:AWSPolicy)--(principal) + WHERE target_policy.arn CONTAINS $provider_uid + AND any(resource IN stmt.resource WHERE + resource = '*' + OR target_policy.arn CONTAINS resource + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-002 +AWS_IAM_PRIVESC_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( + id="aws-iam-privesc-create-access-key", + name="Access Key Creation for Lateral Movement (IAM-002)", + short_description="Create access keys for other IAM users to gain their permissions and move laterally across the account.", + description="Detect principals who can create access keys for other IAM users. This allows generating new credentials for any target user within the resource scope, immediately gaining that user's permissions without needing their password or existing keys.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-002 - iam:CreateAccessKey", + link="https://pathfinding.cloud/paths/iam-002", + ), + provider="aws", + cypher=f""" + // Find principals with iam:CreateAccessKey permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:createaccesskey' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target users that the principal can create access keys for + MATCH path_target = (aws)--(target_user:AWSUser) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-003 +AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( + id="aws-iam-privesc-delete-create-access-key", + name="Access Key Rotation Attack for Lateral Movement (IAM-003)", + short_description="Delete and recreate access keys for other IAM users to bypass the two-key limit and gain their permissions.", + description="Detect principals who can both delete and create access keys for other IAM users. This variation of IAM-002 handles the scenario where a target user already has the maximum of two access keys by first deleting one, then creating a replacement under the attacker's control.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-003 - iam:CreateAccessKey + iam:DeleteAccessKey", + link="https://pathfinding.cloud/paths/iam-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:CreateAccessKey permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:createaccesskey' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find iam:DeleteAccessKey permission + MATCH (principal)--(delete_policy:AWSPolicy)--(stmt_delete:AWSPolicyStatement) + WHERE stmt_delete.effect = 'Allow' + AND any(action IN stmt_delete.action WHERE + toLower(action) = 'iam:deleteaccesskey' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target users that the principal can rotate access keys for + MATCH path_target = (aws)--(target_user:AWSUser) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + AND any(resource IN stmt_delete.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-004 +AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE = AttackPathsQueryDefinition( + id="aws-iam-privesc-create-login-profile", + name="Console Login Profile Creation for Lateral Movement (IAM-004)", + short_description="Create console login profiles for other IAM users to access the AWS Console with their permissions.", + description="Detect principals who can create console login profiles for other IAM users. By setting a known password on a target user that lacks a login profile, the attacker gains AWS Console access with that user's permissions without needing their existing credentials.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-004 - iam:CreateLoginProfile", + link="https://pathfinding.cloud/paths/iam-004", + ), + provider="aws", + cypher=f""" + // Find principals with iam:CreateLoginProfile permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:createloginprofile' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target users that the principal can create login profiles for + MATCH path_target = (aws)--(target_user:AWSUser) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-005 +AWS_IAM_PRIVESC_PUT_ROLE_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-put-role-policy", + name="Inline Policy Injection for Self-Escalation (IAM-005)", + short_description="Attach an inline policy with administrative permissions to your own role, instantly escalating privileges.", + description="Detect roles that can use iam:PutRolePolicy on themselves. A role with this permission can attach an inline policy granting any permissions, including full administrative access, without needing to modify or assume any other resource.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-005 - iam:PutRolePolicy", + link="https://pathfinding.cloud/paths/iam-005", + ), + provider="aws", + cypher=f""" + // Find roles with iam:PutRolePolicy permission scoped to themselves + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:putrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + AND any(resource IN stmt.resource WHERE + resource = '*' + OR role.arn CONTAINS resource + OR resource CONTAINS role.name + ) + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-006 +AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE = AttackPathsQueryDefinition( + id="aws-iam-privesc-update-login-profile", + name="Console Password Override for Lateral Movement (IAM-006)", + short_description="Change the console password of other IAM users to log in as them and gain their permissions.", + description="Detect principals who can update console login profiles for other IAM users. By resetting a target user's password, the attacker gains AWS Console access with that user's permissions. Unlike IAM-004, this targets users who already have a login profile configured.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-006 - iam:UpdateLoginProfile", + link="https://pathfinding.cloud/paths/iam-006", + ), + provider="aws", + cypher=f""" + // Find principals with iam:UpdateLoginProfile permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:updateloginprofile' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target users that the principal can update login profiles for + MATCH path_target = (aws)--(target_user:AWSUser) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-007 +AWS_IAM_PRIVESC_PUT_USER_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-put-user-policy", + name="Inline Policy Injection on User for Self-Escalation (IAM-007)", + short_description="Attach an inline policy with administrative permissions to your own IAM user, instantly escalating privileges.", + description="Detect IAM users that can use iam:PutUserPolicy on themselves. A user with this permission can attach an inline policy granting any permissions, including full administrative access, without needing to modify or assume any other resource. This is the user equivalent of IAM-005 (PutRolePolicy).", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-007 - iam:PutUserPolicy", + link="https://pathfinding.cloud/paths/iam-007", + ), + provider="aws", + cypher=f""" + // Find users with iam:PutUserPolicy permission scoped to themselves + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:putuserpolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + AND any(resource IN stmt.resource WHERE + resource = '*' + OR user.arn CONTAINS resource + OR resource CONTAINS user.name + ) + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-008 +AWS_IAM_PRIVESC_ATTACH_USER_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-user-policy", + name="Managed Policy Attachment on User for Self-Escalation (IAM-008)", + short_description="Attach existing managed policies with administrative permissions to your own IAM user, instantly escalating privileges.", + description="Detect IAM users that can use iam:AttachUserPolicy on themselves. A user with this permission can attach any existing managed policy, including AdministratorAccess, to themselves without needing to modify or assume any other resource. Unlike IAM-007 (PutUserPolicy), this requires an existing managed policy with elevated permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-008 - iam:AttachUserPolicy", + link="https://pathfinding.cloud/paths/iam-008", + ), + provider="aws", + cypher=f""" + // Find users with iam:AttachUserPolicy permission scoped to themselves + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:attachuserpolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + AND any(resource IN stmt.resource WHERE + resource = '*' + OR user.arn CONTAINS resource + OR resource CONTAINS user.name + ) + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-009 +AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-role-policy", + name="Managed Policy Attachment on Role for Self-Escalation (IAM-009)", + short_description="Attach existing managed policies with administrative permissions to your own IAM role, instantly escalating privileges.", + description="Detect IAM roles that can use iam:AttachRolePolicy on themselves. A role with this permission can attach any existing managed policy, including AdministratorAccess, to itself without needing to modify or assume any other resource. Unlike IAM-005 (PutRolePolicy), this requires an existing managed policy with elevated permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-009 - iam:AttachRolePolicy", + link="https://pathfinding.cloud/paths/iam-009", + ), + provider="aws", + cypher=f""" + // Find roles with iam:AttachRolePolicy permission scoped to themselves + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:attachrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + AND any(resource IN stmt.resource WHERE + resource = '*' + OR role.arn CONTAINS resource + OR resource CONTAINS role.name + ) + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-010 +AWS_IAM_PRIVESC_ATTACH_GROUP_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-group-policy", + name="Managed Policy Attachment on Group for Self-Escalation (IAM-010)", + short_description="Attach existing managed policies with administrative permissions to a group you belong to, escalating privileges for all group members.", + description="Detect IAM users that can use iam:AttachGroupPolicy on a group they are a member of. A user with this permission can attach any existing managed policy, including AdministratorAccess, to a group they belong to, immediately escalating privileges for all group members.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-010 - iam:AttachGroupPolicy", + link="https://pathfinding.cloud/paths/iam-010", + ), + provider="aws", + cypher=f""" + // Find users with iam:AttachGroupPolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:attachgrouppolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find groups the user is a member of and can attach policies to + MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_group.arn CONTAINS resource + OR resource CONTAINS target_group.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-011 +AWS_IAM_PRIVESC_PUT_GROUP_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-put-group-policy", + name="Inline Policy Injection on Group for Self-Escalation (IAM-011)", + short_description="Attach an inline policy with administrative permissions to a group you belong to, escalating privileges for all group members.", + description="Detect IAM users that can use iam:PutGroupPolicy on a group they are a member of. A user with this permission can attach an inline policy granting any permissions to a group they belong to, immediately escalating privileges for all group members. Unlike IAM-010, this does not require an existing managed policy.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-011 - iam:PutGroupPolicy", + link="https://pathfinding.cloud/paths/iam-011", + ), + provider="aws", + cypher=f""" + // Find users with iam:PutGroupPolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:putgrouppolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find groups the user is a member of and can put policies on + MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_group.arn CONTAINS resource + OR resource CONTAINS target_group.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-012 +AWS_IAM_PRIVESC_UPDATE_ASSUME_ROLE_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-update-assume-role-policy", + name="Trust Policy Hijacking for Role Assumption (IAM-012)", + short_description="Modify a role's trust policy to allow yourself to assume it, gaining the role's permissions.", + description="Detect principals who can update the assume role policy (trust policy) of other IAM roles. By modifying a target role's trust policy to trust the attacker's principal, the attacker can then assume the role and gain all its permissions, including potential administrative access.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-012 - iam:UpdateAssumeRolePolicy", + link="https://pathfinding.cloud/paths/iam-012", + ), + provider="aws", + cypher=f""" + // Find principals with iam:UpdateAssumeRolePolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:updateassumerolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles whose trust policy can be modified + MATCH path_target = (aws)--(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-013 +AWS_IAM_PRIVESC_ADD_USER_TO_GROUP = AttackPathsQueryDefinition( + id="aws-iam-privesc-add-user-to-group", + name="Group Membership Hijacking for Privilege Escalation (IAM-013)", + short_description="Add yourself to a privileged IAM group to inherit its permissions, gaining access to all policies attached to the group.", + description="Detect principals who can add users to IAM groups. By adding themselves to a group with elevated permissions such as AdministratorAccess, the attacker immediately inherits all policies attached to that group. The level of access gained depends on the permissions of the target group.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-013 - iam:AddUserToGroup", + link="https://pathfinding.cloud/paths/iam-013", + ), + provider="aws", + cypher=f""" + // Find principals with iam:AddUserToGroup permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:addusertogroup' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target groups the principal can add users to + MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_group.arn CONTAINS resource + OR resource CONTAINS target_group.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-014 +AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-role-policy-assume-role", + name="Managed Policy Attachment with Role Assumption for Lateral Movement (IAM-014)", + short_description="Attach administrative managed policies to another role you can assume, then assume it to gain elevated privileges.", + description="Detect principals who can attach managed policies to a different IAM role and also assume that role. By attaching AdministratorAccess to a target role and then assuming it, the attacker gains full administrative access. This is a variation of IAM-009 for lateral movement where the principal targets another assumable role instead of their own.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-014 - iam:AttachRolePolicy + sts:AssumeRole", + link="https://pathfinding.cloud/paths/iam-014", + ), + provider="aws", + cypher=f""" + // Find principals with iam:AttachRolePolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:attachrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles the principal can assume and attach policies to + MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-015 +AWS_IAM_PRIVESC_ATTACH_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-user-policy-create-access-key", + name="Managed Policy Attachment with Access Key Creation for Lateral Movement (IAM-015)", + short_description="Attach administrative managed policies to another IAM user and create access keys for them to gain programmatic access with elevated privileges.", + description="Detect principals who can attach managed policies to another IAM user and also create access keys for that user. By attaching AdministratorAccess to a target user and creating access keys, the attacker gains programmatic access with the target user's elevated permissions. This combines IAM-008 (AttachUserPolicy) with IAM-002 (CreateAccessKey) for lateral movement.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-015 - iam:AttachUserPolicy + iam:CreateAccessKey", + link="https://pathfinding.cloud/paths/iam-015", + ), + provider="aws", + cypher=f""" + // Find principals with iam:AttachUserPolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:attachuserpolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find iam:CreateAccessKey permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'iam:createaccesskey' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target users the principal can attach policies to and create keys for + MATCH path_target = (aws)--(target_user:AWSUser) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + AND any(resource IN stmt2.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-016 +AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-create-policy-version-assume-role", + name="Policy Version Override with Role Assumption for Lateral Movement (IAM-016)", + short_description="Create a new version of a customer-managed policy attached to another role with administrative permissions, then assume that role to gain elevated access.", + description="Detect principals who can create new versions of customer-managed policies attached to other roles and also assume those roles. By creating a new policy version with administrative permissions on a policy attached to a target role, then assuming that role, the attacker gains full administrative access. This is a variation of IAM-001 for lateral movement where the modified policy is attached to an assumable role rather than the attacker's own principal.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-016 - iam:CreatePolicyVersion + sts:AssumeRole", + link="https://pathfinding.cloud/paths/iam-016", + ), + provider="aws", + cypher=f""" + // Find principals with iam:CreatePolicyVersion permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:createpolicyversion' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles the principal can assume that have customer-managed policies the principal can modify + MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) + MATCH (target_role)--(target_policy:AWSPolicy) + WHERE target_policy.arn CONTAINS $provider_uid + AND any(resource IN stmt.resource WHERE + resource = '*' + OR target_policy.arn CONTAINS resource + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-017 +AWS_IAM_PRIVESC_PUT_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-put-role-policy-assume-role", + name="Inline Policy Injection with Role Assumption for Lateral Movement (IAM-017)", + short_description="Attach an inline policy with administrative permissions to another role you can assume, then assume it to gain elevated privileges.", + description="Detect principals who can add inline policies to a different IAM role and also assume that role. By adding an inline policy granting administrative permissions to a target role and then assuming it, the attacker gains full administrative access. This is a variation of IAM-005 for lateral movement where the principal targets another assumable role instead of their own.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-017 - iam:PutRolePolicy + sts:AssumeRole", + link="https://pathfinding.cloud/paths/iam-017", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PutRolePolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:putrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles the principal can assume and put inline policies on + MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-018 +AWS_IAM_PRIVESC_PUT_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( + id="aws-iam-privesc-put-user-policy-create-access-key", + name="Inline Policy Injection with Access Key Creation for Lateral Movement (IAM-018)", + short_description="Attach an inline policy with administrative permissions to another IAM user and create access keys for them to gain programmatic access with elevated privileges.", + description="Detect principals who can add inline policies to another IAM user and also create access keys for that user. By adding an administrative inline policy to a target user and creating access keys, the attacker gains programmatic access with the target user's elevated permissions. This combines IAM-007 (PutUserPolicy) with IAM-002 (CreateAccessKey) for lateral movement.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-018 - iam:PutUserPolicy + iam:CreateAccessKey", + link="https://pathfinding.cloud/paths/iam-018", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PutUserPolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:putuserpolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find iam:CreateAccessKey permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'iam:createaccesskey' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target users the principal can put policies on and create keys for + MATCH path_target = (aws)--(target_user:AWSUser) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + AND any(resource IN stmt2.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-019 +AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-role-policy-update-assume-role", + name="Managed Policy Attachment with Trust Policy Hijacking for Privilege Escalation (IAM-019)", + short_description="Attach administrative managed policies to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access.", + description="Detect principals who can attach managed policies to an IAM role and also update that role's trust policy. By attaching AdministratorAccess and modifying the trust policy to allow the attacker, the principal can then assume the role without needing pre-existing sts:AssumeRole permission. This combines IAM-009 (AttachRolePolicy) with IAM-012 (UpdateAssumeRolePolicy).", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-019 - iam:AttachRolePolicy + iam:UpdateAssumeRolePolicy", + link="https://pathfinding.cloud/paths/iam-019", + ), + provider="aws", + cypher=f""" + // Find principals with iam:AttachRolePolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:attachrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find iam:UpdateAssumeRolePolicy permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'iam:updateassumerolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles the principal can attach policies to and update trust policy for + MATCH path_target = (aws)--(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + AND any(resource IN stmt2.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-020 +AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-create-policy-version-update-assume-role", + name="Policy Version Override with Trust Policy Hijacking for Privilege Escalation (IAM-020)", + short_description="Create a new version of a customer-managed policy attached to a role with administrative permissions and modify its trust policy to assume it, without prior assume-role access.", + description="Detect principals who can create new versions of customer-managed policies attached to roles and also update those roles' trust policies. By creating an administrative policy version and modifying the trust policy to allow the attacker, the principal can assume the role without needing pre-existing sts:AssumeRole permission. This combines IAM-001 (CreatePolicyVersion) with IAM-012 (UpdateAssumeRolePolicy).", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-020 - iam:CreatePolicyVersion + iam:UpdateAssumeRolePolicy", + link="https://pathfinding.cloud/paths/iam-020", + ), + provider="aws", + cypher=f""" + // Find principals with iam:CreatePolicyVersion permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:createpolicyversion' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find iam:UpdateAssumeRolePolicy permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'iam:updateassumerolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles with customer-managed policies the principal can modify and update trust policy for + MATCH path_target = (aws)--(target_role:AWSRole) + WHERE any(resource IN stmt2.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + MATCH (target_role)--(target_policy:AWSPolicy) + WHERE target_policy.arn CONTAINS $provider_uid + AND any(resource IN stmt.resource WHERE + resource = '*' + OR target_policy.arn CONTAINS resource + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-021 +AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-put-role-policy-update-assume-role", + name="Inline Policy Injection with Trust Policy Hijacking for Privilege Escalation (IAM-021)", + short_description="Add an inline policy with administrative permissions to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access.", + description="Detect principals who can add inline policies to an IAM role and also update that role's trust policy. By adding an administrative inline policy and modifying the trust policy to allow the attacker, the principal can then assume the role without needing pre-existing sts:AssumeRole permission. This combines IAM-005 (PutRolePolicy) with IAM-012 (UpdateAssumeRolePolicy).", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-021 - iam:PutRolePolicy + iam:UpdateAssumeRolePolicy", + link="https://pathfinding.cloud/paths/iam-021", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PutRolePolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:putrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find iam:UpdateAssumeRolePolicy permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'iam:updateassumerolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles the principal can put inline policies on and update trust policy for + MATCH path_target = (aws)--(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + AND any(resource IN stmt2.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# LAMBDA-001 +AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION = AttackPathsQueryDefinition( + id="aws-lambda-privesc-passrole-create-function", + name="Lambda Function Creation with Privileged Role (LAMBDA-001)", + short_description="Create a Lambda function with a privileged IAM role and invoke it to execute code with that role's permissions.", + description="Detect principals who can create Lambda functions with privileged IAM roles and invoke them. By passing a privileged role to a new Lambda function and invoking it, the attacker executes code with the role's permissions, gaining access to any resources the role can access.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - LAMBDA-001 - iam:PassRole + lambda:CreateFunction + lambda:InvokeFunction", + link="https://pathfinding.cloud/paths/lambda-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find lambda:CreateFunction permission + MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'lambda:createfunction' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find lambda:InvokeFunction permission + MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement) + WHERE stmt_invoke.effect = 'Allow' + AND any(action IN stmt_invoke.action WHERE + toLower(action) = 'lambda:invokefunction' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find roles that trust Lambda service (can be passed to Lambda) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# LAMBDA-002 +AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_EVENT_SOURCE = AttackPathsQueryDefinition( + id="aws-lambda-privesc-passrole-create-function-event-source", + name="Lambda Function Creation with Event Source Trigger (LAMBDA-002)", + short_description="Create a Lambda function with a privileged IAM role and an event source mapping to trigger it automatically, executing code with the role's permissions.", + description="Detect principals who can create Lambda functions with privileged IAM roles and configure event source mappings to trigger them. By passing a privileged role to a new Lambda function and creating an event source mapping (DynamoDB stream, Kinesis, SQS), the attacker executes code with elevated privileges without needing to invoke the function directly.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - LAMBDA-002 - iam:PassRole + lambda:CreateFunction + lambda:CreateEventSourceMapping", + link="https://pathfinding.cloud/paths/lambda-002", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find lambda:CreateFunction permission + MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'lambda:createfunction' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find lambda:CreateEventSourceMapping permission + MATCH (principal)--(event_policy:AWSPolicy)--(stmt_event:AWSPolicyStatement) + WHERE stmt_event.effect = 'Allow' + AND any(action IN stmt_event.action WHERE + toLower(action) = 'lambda:createeventsourcemapping' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find roles that trust Lambda service (can be passed to Lambda) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# LAMBDA-003 +AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE = AttackPathsQueryDefinition( + id="aws-lambda-privesc-update-function-code", + name="Lambda Function Code Injection (LAMBDA-003)", + short_description="Modify the code of an existing Lambda function to execute arbitrary commands with the function's execution role permissions.", + description="Detect principals who can update the code of existing Lambda functions. By replacing a Lambda function's code with malicious code, the attacker executes arbitrary commands with the privileges of the function's execution role when it is next invoked, either manually or via automatic triggers.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - LAMBDA-003 - lambda:UpdateFunctionCode", + link="https://pathfinding.cloud/paths/lambda-003", + ), + provider="aws", + cypher=f""" + // Find principals with lambda:UpdateFunctionCode permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'lambda:updatefunctioncode' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find existing Lambda functions with execution roles + MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR lambda_fn.arn CONTAINS resource + OR resource CONTAINS lambda_fn.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# LAMBDA-004 +AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_INVOKE = AttackPathsQueryDefinition( + id="aws-lambda-privesc-update-function-code-invoke", + name="Lambda Function Code Injection with Direct Invocation (LAMBDA-004)", + short_description="Modify the code of an existing Lambda function and invoke it directly to execute arbitrary commands with the function's execution role permissions.", + description="Detect principals who can update the code of existing Lambda functions and invoke them. By replacing a Lambda function's code with malicious code and invoking it directly, the attacker executes arbitrary commands with the privileges of the function's execution role immediately, without waiting for automatic triggers.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - LAMBDA-004 - lambda:UpdateFunctionCode + lambda:InvokeFunction", + link="https://pathfinding.cloud/paths/lambda-004", + ), + provider="aws", + cypher=f""" + // Find principals with lambda:UpdateFunctionCode permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'lambda:updatefunctioncode' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find lambda:InvokeFunction permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'lambda:invokefunction' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find existing Lambda functions with execution roles + MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR lambda_fn.arn CONTAINS resource + OR resource CONTAINS lambda_fn.name + ) + AND any(resource IN stmt2.resource WHERE + resource = '*' + OR lambda_fn.arn CONTAINS resource + OR resource CONTAINS lambda_fn.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# LAMBDA-005 +AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_ADD_PERMISSION = AttackPathsQueryDefinition( + id="aws-lambda-privesc-update-function-code-add-permission", + name="Lambda Function Code Injection with Resource Policy Grant (LAMBDA-005)", + short_description="Modify the code of an existing Lambda function and grant yourself invocation permission via its resource-based policy to execute code with the function's execution role.", + description="Detect principals who can update the code of existing Lambda functions and add permissions to their resource-based policies. By replacing a Lambda function's code and granting themselves invoke access through the resource-based policy, the attacker executes malicious code with the function's execution role without needing lambda:InvokeFunction as an IAM permission.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - LAMBDA-005 - lambda:UpdateFunctionCode + lambda:AddPermission", + link="https://pathfinding.cloud/paths/lambda-005", + ), + provider="aws", + cypher=f""" + // Find principals with lambda:UpdateFunctionCode permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'lambda:updatefunctioncode' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find lambda:AddPermission permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'lambda:addpermission' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find existing Lambda functions with execution roles + MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR lambda_fn.arn CONTAINS resource + OR resource CONTAINS lambda_fn.name + ) + AND any(resource IN stmt2.resource WHERE + resource = '*' + OR lambda_fn.arn CONTAINS resource + OR resource CONTAINS lambda_fn.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# LAMBDA-006 +AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION = AttackPathsQueryDefinition( + id="aws-lambda-privesc-passrole-create-function-add-permission", + name="Lambda Function Creation with Resource Policy Invocation (LAMBDA-006)", + short_description="Create a Lambda function with a privileged IAM role and grant yourself invocation permission via its resource-based policy to execute code with the role's permissions.", + description="Detect principals who can create Lambda functions with privileged IAM roles and add permissions to their resource-based policies. By passing a privileged role to a new Lambda function and granting themselves invoke access through the resource-based policy, the attacker executes malicious code with elevated privileges without needing lambda:InvokeFunction as an IAM permission.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - LAMBDA-006 - iam:PassRole + lambda:CreateFunction + lambda:AddPermission", + link="https://pathfinding.cloud/paths/lambda-006", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find lambda:CreateFunction permission + MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'lambda:createfunction' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find lambda:AddPermission permission + MATCH (principal)--(perm_policy:AWSPolicy)--(stmt_perm:AWSPolicyStatement) + WHERE stmt_perm.effect = 'Allow' + AND any(action IN stmt_perm.action WHERE + toLower(action) = 'lambda:addpermission' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find roles that trust Lambda service (can be passed to Lambda) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SAGEMAKER-001 +AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK = AttackPathsQueryDefinition( + id="aws-sagemaker-privesc-passrole-create-notebook", + name="SageMaker Notebook Creation with Privileged Role (SAGEMAKER-001)", + short_description="Create a SageMaker notebook instance with a privileged IAM role to execute arbitrary code with the role's permissions via the Jupyter environment.", + description="Detect principals who can create SageMaker notebook instances with privileged IAM roles. By passing a privileged role to a new notebook instance, the attacker gains shell access through the Jupyter environment and can execute arbitrary commands with the role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SAGEMAKER-001 - iam:PassRole + sagemaker:CreateNotebookInstance", + link="https://pathfinding.cloud/paths/sagemaker-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find sagemaker:CreateNotebookInstance permission + MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement) + WHERE stmt_sm.effect = 'Allow' + AND any(action IN stmt_sm.action WHERE + toLower(action) = 'sagemaker:createnotebookinstance' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find roles that trust SageMaker service (can be passed to SageMaker) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SAGEMAKER-002 +AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_TRAINING_JOB = AttackPathsQueryDefinition( + id="aws-sagemaker-privesc-passrole-create-training-job", + name="SageMaker Training Job Creation with Privileged Role (SAGEMAKER-002)", + short_description="Create a SageMaker training job with a privileged IAM role to execute arbitrary container code with the role's permissions.", + description="Detect principals who can create SageMaker training jobs with privileged IAM roles. By passing a privileged role to a new training job with a malicious training script or container, the attacker executes code with elevated privileges and can exfiltrate credentials or modify AWS resources.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SAGEMAKER-002 - iam:PassRole + sagemaker:CreateTrainingJob", + link="https://pathfinding.cloud/paths/sagemaker-002", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find sagemaker:CreateTrainingJob permission + MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement) + WHERE stmt_sm.effect = 'Allow' + AND any(action IN stmt_sm.action WHERE + toLower(action) = 'sagemaker:createtrainingjob' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find roles that trust SageMaker service (can be passed to SageMaker) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SAGEMAKER-003 +AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_PROCESSING_JOB = AttackPathsQueryDefinition( + id="aws-sagemaker-privesc-passrole-create-processing-job", + name="SageMaker Processing Job Creation with Privileged Role (SAGEMAKER-003)", + short_description="Create a SageMaker processing job with a privileged IAM role to execute arbitrary container code with the role's permissions.", + description="Detect principals who can create SageMaker processing jobs with privileged IAM roles. By passing a privileged role to a new processing job with a malicious script or container, the attacker executes code with elevated privileges and can exfiltrate credentials or modify AWS resources.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SAGEMAKER-003 - iam:PassRole + sagemaker:CreateProcessingJob", + link="https://pathfinding.cloud/paths/sagemaker-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find sagemaker:CreateProcessingJob permission + MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement) + WHERE stmt_sm.effect = 'Allow' + AND any(action IN stmt_sm.action WHERE + toLower(action) = 'sagemaker:createprocessingjob' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find roles that trust SageMaker service (can be passed to SageMaker) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SAGEMAKER-004 +AWS_SAGEMAKER_PRIVESC_PRESIGNED_NOTEBOOK_URL = AttackPathsQueryDefinition( + id="aws-sagemaker-privesc-presigned-notebook-url", + name="SageMaker Presigned Notebook URL for Privilege Escalation (SAGEMAKER-004)", + short_description="Generate a presigned URL to access an existing SageMaker notebook instance and execute code with its execution role's permissions.", + description="Detect principals who can generate presigned URLs to access existing SageMaker notebook instances. By accessing the Jupyter environment via a presigned URL, the attacker can execute arbitrary code with the permissions of the notebook's execution role without creating any new resources.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SAGEMAKER-004 - sagemaker:CreatePresignedNotebookInstanceUrl", + link="https://pathfinding.cloud/paths/sagemaker-004", + ), + provider="aws", + cypher=f""" + // Find principals with sagemaker:CreatePresignedNotebookInstanceUrl permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'sagemaker:createpresignednotebookinstanceurl' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find existing SageMaker notebook instances with execution roles + MATCH path_target = (aws)-[:RESOURCE]->(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR notebook.arn CONTAINS resource + OR resource CONTAINS notebook.notebook_instance_name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SAGEMAKER-005 +AWS_SAGEMAKER_PRIVESC_LIFECYCLE_CONFIG_NOTEBOOK = AttackPathsQueryDefinition( + id="aws-sagemaker-privesc-lifecycle-config-notebook", + name="SageMaker Notebook Lifecycle Config Injection (SAGEMAKER-005)", + short_description="Inject a malicious lifecycle configuration into an existing SageMaker notebook to execute code with the notebook's execution role during startup.", + description="Detect principals who can inject malicious lifecycle configurations into existing SageMaker notebook instances. By stopping a notebook, attaching a malicious lifecycle config, and restarting it, the attacker executes arbitrary code with the notebook's execution role permissions during startup.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SAGEMAKER-005 - sagemaker:CreateNotebookInstanceLifecycleConfig + sagemaker:StopNotebookInstance + sagemaker:UpdateNotebookInstance + sagemaker:StartNotebookInstance", + link="https://pathfinding.cloud/paths/sagemaker-005", + ), + provider="aws", + cypher=f""" + // Find principals with sagemaker:CreateNotebookInstanceLifecycleConfig permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'sagemaker:createnotebookinstancelifecycleconfig' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find sagemaker:UpdateNotebookInstance permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'sagemaker:updatenotebookinstance' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find sagemaker:StopNotebookInstance permission + MATCH (principal)--(policy3:AWSPolicy)--(stmt3:AWSPolicyStatement) + WHERE stmt3.effect = 'Allow' + AND any(action IN stmt3.action WHERE + toLower(action) = 'sagemaker:stopnotebookinstance' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find sagemaker:StartNotebookInstance permission + MATCH (principal)--(policy4:AWSPolicy)--(stmt4:AWSPolicyStatement) + WHERE stmt4.effect = 'Allow' + AND any(action IN stmt4.action WHERE + toLower(action) = 'sagemaker:startnotebookinstance' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find existing SageMaker notebook instances with execution roles + MATCH path_target = (aws)-[:RESOURCE]->(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole) + WHERE any(resource IN stmt2.resource WHERE + resource = '*' + OR notebook.arn CONTAINS resource + OR resource CONTAINS notebook.notebook_instance_name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SSM-001 +AWS_SSM_PRIVESC_START_SESSION = AttackPathsQueryDefinition( + id="aws-ssm-privesc-start-session", + name="SSM Session Access for EC2 Role Credentials (SSM-001)", + short_description="Start an SSM session on an EC2 instance to access its attached role credentials through IMDS.", + description="Detect principals who can start SSM sessions on EC2 instances. This allows establishing a shell session on a running EC2 instance and retrieving the attached IAM role's temporary credentials from the Instance Metadata Service (IMDS), gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SSM-001 - ssm:StartSession", + link="https://pathfinding.cloud/paths/ssm-001", + ), + provider="aws", + cypher=f""" + // Find principals with ssm:StartSession permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'ssm:startsession' + OR toLower(action) = 'ssm:*' + OR action = '*' + ) + + // Find EC2 instances with attached roles (targets for credential theft via IMDS) + MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SSM-002 +AWS_SSM_PRIVESC_SEND_COMMAND = AttackPathsQueryDefinition( + id="aws-ssm-privesc-send-command", + name="SSM Send Command for EC2 Role Credentials (SSM-002)", + short_description="Execute commands on an EC2 instance via SSM Run Command to access its attached role credentials through IMDS.", + description="Detect principals who can send SSM commands to EC2 instances. This allows executing arbitrary commands on a running EC2 instance and retrieving the attached IAM role's temporary credentials from the Instance Metadata Service (IMDS), gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SSM-002 - ssm:SendCommand", + link="https://pathfinding.cloud/paths/ssm-002", + ), + provider="aws", + cypher=f""" + // Find principals with ssm:SendCommand permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'ssm:sendcommand' + OR toLower(action) = 'ssm:*' + OR action = '*' + ) + + // Find EC2 instances with attached roles (targets for credential theft via IMDS) + MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# STS-001 +AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-sts-privesc-assume-role", + name="Role Assumption for Privilege Escalation (STS-001)", + short_description="Assume IAM roles with elevated permissions by exploiting bidirectional trust between the starting principal and the target role.", + description="Detect principals who can assume other IAM roles via sts:AssumeRole. When a principal has sts:AssumeRole permission and the target role's trust policy allows the principal to assume it (bidirectional trust), the attacker gains all permissions of the target role. This enables privilege escalation when the target role has higher privileges than the starting principal.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - STS-001 - sts:AssumeRole", + link="https://pathfinding.cloud/paths/sts-001", + ), + provider="aws", + cypher=f""" + // Find principals with sts:AssumeRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'sts:assumerole' + OR toLower(action) = 'sts:*' + OR action = '*' + ) + + // Find target roles the principal can assume (bidirectional trust via Cartography) + MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# AWS Queries List +# ---------------- + +AWS_DEPRECATED_QUERIES: list[AttackPathsQueryDefinition] = [ + AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS, + AWS_RDS_INSTANCES, + AWS_RDS_UNENCRYPTED_STORAGE, + AWS_S3_ANONYMOUS_ACCESS_BUCKETS, + AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS, + AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY, + AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS, + AWS_EC2_INSTANCES_INTERNET_EXPOSED, + AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING, + AWS_CLASSIC_ELB_INTERNET_EXPOSED, + AWS_ELBV2_INTERNET_EXPOSED, + AWS_PUBLIC_IP_RESOURCE_LOOKUP, + AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE, + AWS_APPRUNNER_PRIVESC_UPDATE_SERVICE, + AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER, + AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER, + AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK, + AWS_CLOUDFORMATION_PRIVESC_UPDATE_STACK, + AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACKSET, + AWS_CLOUDFORMATION_PRIVESC_PASSROLE_UPDATE_STACKSET, + AWS_CLOUDFORMATION_PRIVESC_CHANGESET, + AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT, + AWS_CODEBUILD_PRIVESC_START_BUILD, + AWS_CODEBUILD_PRIVESC_START_BUILD_BATCH, + AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH, + AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE, + AWS_EC2_PRIVESC_PASSROLE_IAM, + AWS_EC2_PRIVESC_MODIFY_INSTANCE_ATTRIBUTE, + AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES, + AWS_EC2_PRIVESC_LAUNCH_TEMPLATE, + AWS_EC2INSTANCECONNECT_PRIVESC_SEND_SSH_PUBLIC_KEY, + AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE, + AWS_ECS_PRIVESC_PASSROLE_RUN_TASK, + AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE_EXISTING_CLUSTER, + AWS_ECS_PRIVESC_PASSROLE_RUN_TASK_EXISTING_CLUSTER, + AWS_ECS_PRIVESC_PASSROLE_START_TASK_EXISTING_CLUSTER, + AWS_ECS_PRIVESC_EXECUTE_COMMAND, + AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT, + AWS_GLUE_PRIVESC_UPDATE_DEV_ENDPOINT, + AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB, + AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB_TRIGGER, + AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB, + AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER, + AWS_IAM_PRIVESC_CREATE_POLICY_VERSION, + AWS_IAM_PRIVESC_CREATE_ACCESS_KEY, + AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY, + AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE, + AWS_IAM_PRIVESC_PUT_ROLE_POLICY, + AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE, + AWS_IAM_PRIVESC_PUT_USER_POLICY, + AWS_IAM_PRIVESC_ATTACH_USER_POLICY, + AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY, + AWS_IAM_PRIVESC_ATTACH_GROUP_POLICY, + AWS_IAM_PRIVESC_PUT_GROUP_POLICY, + AWS_IAM_PRIVESC_UPDATE_ASSUME_ROLE_POLICY, + AWS_IAM_PRIVESC_ADD_USER_TO_GROUP, + AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE, + AWS_IAM_PRIVESC_ATTACH_USER_POLICY_CREATE_ACCESS_KEY, + AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_ASSUME_ROLE, + AWS_IAM_PRIVESC_PUT_ROLE_POLICY_ASSUME_ROLE, + AWS_IAM_PRIVESC_PUT_USER_POLICY_CREATE_ACCESS_KEY, + AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE, + AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE, + AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE, + AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION, + AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_EVENT_SOURCE, + AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE, + AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_INVOKE, + AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_ADD_PERMISSION, + AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION, + AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK, + AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_TRAINING_JOB, + AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_PROCESSING_JOB, + AWS_SAGEMAKER_PRIVESC_PRESIGNED_NOTEBOOK_URL, + AWS_SAGEMAKER_PRIVESC_LIFECYCLE_CONFIG_NOTEBOOK, + AWS_SSM_PRIVESC_START_SESSION, + AWS_SSM_PRIVESC_SEND_COMMAND, + AWS_STS_PRIVESC_ASSUME_ROLE, +] diff --git a/api/src/backend/api/attack_paths/queries/registry.py b/api/src/backend/api/attack_paths/queries/registry.py index d055b842fd..358b1d6aed 100644 --- a/api/src/backend/api/attack_paths/queries/registry.py +++ b/api/src/backend/api/attack_paths/queries/registry.py @@ -1,12 +1,14 @@ from api.attack_paths.queries.aws import AWS_QUERIES + +# TODO: drop after Neptune cutover +from api.attack_paths.queries.aws_deprecated import AWS_DEPRECATED_QUERIES from api.attack_paths.queries.types import AttackPathsQueryDefinition -# Query definitions organized by provider +# Query definitions for scans synced with the current schema. _QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = { "aws": AWS_QUERIES, } -# Flat lookup by query ID for O(1) access _QUERIES_BY_ID: dict[str, AttackPathsQueryDefinition] = { definition.id: definition for definitions in _QUERY_DEFINITIONS.values() @@ -14,11 +16,45 @@ _QUERIES_BY_ID: dict[str, AttackPathsQueryDefinition] = { } -def get_queries_for_provider(provider: str) -> list[AttackPathsQueryDefinition]: - """Get all attack path queries for a specific provider.""" - return _QUERY_DEFINITIONS.get(provider, []) +# TODO: drop after Neptune cutover +# +# Query definitions for pre-cutover scans (`AttackPathsScan.is_migrated=False`) +# whose graph data was written under the previous schema. Both maps expose the +# same query IDs so the API contract is identical regardless of which set is +# routed to. +_DEPRECATED_QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = { + "aws": AWS_DEPRECATED_QUERIES, +} + +_DEPRECATED_QUERIES_BY_ID: dict[str, AttackPathsQueryDefinition] = { + definition.id: definition + for definitions in _DEPRECATED_QUERY_DEFINITIONS.values() + for definition in definitions +} -def get_query_by_id(query_id: str) -> AttackPathsQueryDefinition | None: - """Get a specific attack path query by its ID.""" - return _QUERIES_BY_ID.get(query_id) +def get_queries_for_provider( + provider: str, + is_migrated: bool = True, +) -> list[AttackPathsQueryDefinition]: + """Get all attack path queries for a provider. + + `is_migrated` selects the catalog: True for scans synced with the current + schema, False for pre-cutover scans still using the legacy graph shape. + # TODO: drop the `is_migrated` parameter after Neptune cutover + """ + catalog = _QUERY_DEFINITIONS if is_migrated else _DEPRECATED_QUERY_DEFINITIONS + return catalog.get(provider, []) + + +def get_query_by_id( + query_id: str, + is_migrated: bool = True, +) -> AttackPathsQueryDefinition | None: + """Get a specific attack path query by ID. + + `is_migrated` selects the catalog (see `get_queries_for_provider`). + # TODO: drop the `is_migrated` parameter after Neptune cutover + """ + by_id = _QUERIES_BY_ID if is_migrated else _DEPRECATED_QUERIES_BY_ID + return by_id.get(query_id) diff --git a/api/src/backend/api/attack_paths/sink/__init__.py b/api/src/backend/api/attack_paths/sink/__init__.py new file mode 100644 index 0000000000..b90fd6e442 --- /dev/null +++ b/api/src/backend/api/attack_paths/sink/__init__.py @@ -0,0 +1,28 @@ +"""Attack-paths sink database layer. + +The sink is the persistent store where attack-paths graphs live after a scan +finishes. Currently selectable between Neo4j (OSS / local dev default) and +AWS Neptune (hosted dev/staging/prod). Backend is picked by the +`ATTACK_PATHS_SINK_DATABASE` setting at process init. + +This package exposes the public factory API; the implementation lives in +`api.attack_paths.sink.factory`. +""" + +from api.attack_paths.sink.factory import ( + SinkBackend, + close, + get_backend, + get_backend_for_name, + get_backend_for_scan, + init, +) + +__all__ = [ + "SinkBackend", + "close", + "get_backend", + "get_backend_for_name", + "get_backend_for_scan", + "init", +] diff --git a/api/src/backend/api/attack_paths/sink/base.py b/api/src/backend/api/attack_paths/sink/base.py new file mode 100644 index 0000000000..0ba4737f5e --- /dev/null +++ b/api/src/backend/api/attack_paths/sink/base.py @@ -0,0 +1,92 @@ +"""Protocol every sink backend must implement.""" + +from contextlib import AbstractContextManager +from typing import Any, Protocol + +import neo4j + + +class SinkDatabase(Protocol): + """Contract for the persistent attack-paths graph store. + + The `database` argument is an opaque identifier passed through from the + legacy `database.py` API surface. On Neo4j it is the per-tenant database + name (e.g. `db-tenant-{uuid}`). On Neptune it is ignored (the cluster + has a single graph, and isolation is label-based). + """ + + def init(self) -> None: ... + + def close(self) -> None: ... + + def verify_connectivity(self) -> None: + """Raise if the backend the API read path uses is unreachable. + + Neo4j verifies its single driver. Neptune verifies the reader + driver (the endpoint the API serves reads from); on single-endpoint + clusters the reader aliases the writer, so that path is covered too. + Used by the readiness probe; must not block longer than the caller's + probe budget. + """ + ... + + def get_session( + self, + database: str | None = None, + default_access_mode: str | None = None, + ) -> AbstractContextManager: ... + + def execute_read_query( + self, + database: str, + cypher: str, + parameters: dict[str, Any] | None = None, + ) -> neo4j.graph.Graph: ... + + def create_database(self, database: str) -> None: ... + + def drop_database(self, database: str) -> None: ... + + def drop_subgraph(self, database: str, provider_id: str) -> int: ... + + def has_provider_data(self, database: str, provider_id: str) -> bool: ... + + def clear_cache(self, database: str) -> None: ... + + def ensure_sync_indexes(self, database: str) -> None: + """Create any index needed for the sync write path. + + Called once at the start of each provider sync; must be idempotent. + Neo4j creates a `_provider_element_id` index on `_ProviderResource`; + Neptune is a no-op (its `~id` lookup needs no index). + """ + ... + + def write_nodes( + self, + database: str, + labels: str, + rows: list[dict[str, Any]], + ) -> None: + """Upsert a batch of nodes into the sink. + + `labels` is a pre-rendered Cypher label string ready to drop after + the node variable (e.g. `` `AWSUser`:`_ProviderResource`:`_Tenant_x` ``). + Each row carries `provider_element_id` and `props`. + """ + ... + + def write_relationships( + self, + database: str, + rel_type: str, + provider_id: str, + rows: list[dict[str, Any]], + ) -> None: + """Upsert a batch of relationships into the sink. + + Each row carries `start_element_id`, `end_element_id`, + `provider_element_id` and `props`. `rel_type` is the relationship + type (already a valid Cypher identifier). + """ + ... diff --git a/api/src/backend/api/attack_paths/sink/drop.py b/api/src/backend/api/attack_paths/sink/drop.py new file mode 100644 index 0000000000..9b4044a8f0 --- /dev/null +++ b/api/src/backend/api/attack_paths/sink/drop.py @@ -0,0 +1,78 @@ +"""Shared batched deletion helpers for sink backends.""" + +import logging +import time +from typing import Any + +RELATIONSHIP_DELETE_QUERY_TEMPLATES = { + "outgoing relationship": """ + MATCH (n:`{provider_label}`)-[r]->() + WITH r LIMIT $batch_size + DELETE r + RETURN COUNT(r) AS deleted_rels_count + """, + "incoming relationship": """ + MATCH (n:`{provider_label}`)<-[r]-() + WITH r LIMIT $batch_size + DELETE r + RETURN COUNT(r) AS deleted_rels_count + """, +} + +NODE_DELETE_QUERY_TEMPLATE = """ + MATCH (n:{provider_resource_label}:`{provider_label}`) + WITH n LIMIT $batch_size + DELETE n + RETURN COUNT(n) AS deleted_nodes_count + """ + + +def delete_batches( + *, + session: Any, + logger: logging.Logger, + log_target: str, + provider_id: str, + query: str, + phase: str, + count_key: str, + total_key: str, + deleted_key: str, + initial_total: int, + batch_size: int, + drop_t0: float, +) -> tuple[int, int]: + deleted_total = initial_total + batches = 0 + while True: + logger.info( + "Deleting %s batch from %s " + "(provider=%s, batch=%s, total_%s=%s, elapsed=%.3fs)", + phase, + log_target, + provider_id, + batches + 1, + total_key, + deleted_total, + time.perf_counter() - drop_t0, + ) + record = session.run(query, {"batch_size": batch_size}).single() + deleted = (record[count_key] if record else 0) or 0 + if deleted == 0: + return deleted_total, batches + + batches += 1 + deleted_total += deleted + logger.info( + "Deleted %s batch from %s " + "(provider=%s, batch=%s, %s=%s, total_%s=%s, elapsed=%.3fs)", + phase, + log_target, + provider_id, + batches, + deleted_key, + deleted, + total_key, + deleted_total, + time.perf_counter() - drop_t0, + ) diff --git a/api/src/backend/api/attack_paths/sink/factory.py b/api/src/backend/api/attack_paths/sink/factory.py new file mode 100644 index 0000000000..ad2116fa40 --- /dev/null +++ b/api/src/backend/api/attack_paths/sink/factory.py @@ -0,0 +1,134 @@ +"""Sink backend factory and process-wide handle cache. + +Picks the active backend from `settings.ATTACK_PATHS_SINK_DATABASE` at first +use, holds the active backend plus any secondary backends needed to serve +scans written under the previous configuration, and tears them all down on +process shutdown. Imported via `from api.attack_paths import sink as +sink_module`. +""" + +import threading +from enum import StrEnum, auto + +from api.attack_paths.sink.base import SinkDatabase +from api.models import AttackPathsScan +from django.conf import settings + +# Backend names + + +class SinkBackend(StrEnum): + NEO4J = auto() + NEPTUNE = auto() + + +# Backend cache + +_backend: SinkDatabase | None = None +_secondary_backends: dict[SinkBackend, SinkDatabase] = {} +_lock = threading.Lock() + + +def _resolve_setting() -> SinkBackend: + raw = settings.ATTACK_PATHS_SINK_DATABASE.lower() + try: + return SinkBackend(raw) + + except ValueError: + valid = sorted(b.value for b in SinkBackend) + raise RuntimeError( + f"ATTACK_PATHS_SINK_DATABASE must be one of {valid}; got {raw!r}" + ) + + +def _build_backend(name: SinkBackend) -> SinkDatabase: + if name is SinkBackend.NEO4J: + from api.attack_paths.sink.neo4j import Neo4jSink + + return Neo4jSink() + + if name is SinkBackend.NEPTUNE: + from api.attack_paths.sink.neptune import NeptuneSink + + return NeptuneSink() + + raise RuntimeError(f"Unknown sink backend {name!r}") + + +# Lifecycle + + +def init(name: SinkBackend | str | None = None) -> SinkDatabase: + """Initialize the configured sink backend. Idempotent.""" + global _backend + if _backend is not None: + return _backend + + with _lock: + if _backend is None: + resolved = SinkBackend(name) if name else _resolve_setting() + backend = _build_backend(resolved) + backend.init() + _backend = backend + + return _backend + + +def close() -> None: + """Close the active backend and every cached secondary backend.""" + global _backend + with _lock: + backends = [ + b for b in (_backend, *_secondary_backends.values()) if b is not None + ] + _backend = None + _secondary_backends.clear() + + for backend in backends: + try: + backend.close() + + except Exception: # pragma: no cover - best-effort + pass + + +def get_backend() -> SinkDatabase: + """Return the active sink. Initializes on first call.""" + return init() + + +# Per-scan routing + + +def get_backend_for_scan(scan: AttackPathsScan) -> SinkDatabase: + """Route reads by the sink that stores this scan's graph.""" + raw_backend = getattr(scan, "sink_backend", SinkBackend.NEO4J.value) + if not isinstance(raw_backend, str): + raw_backend = SinkBackend.NEO4J.value + return get_backend_for_name(raw_backend) + + +def get_backend_for_name(name: SinkBackend | str) -> SinkDatabase: + """Return the backend named by persisted scan metadata.""" + resolved = SinkBackend(name) + if resolved is _resolve_setting(): + return get_backend() + + return _build_backend_cached(resolved) + + +def _build_backend_cached(name: SinkBackend) -> SinkDatabase: + # TODO: drop after Neptune cutover + # Needed only during cutover to serve Neo4j-written scans from a Neptune- + # configured API pod (and vice versa). Once every scan is on Neptune, + # `get_backend_for_scan` becomes a one-liner returning `get_backend()`. + if name in _secondary_backends: + return _secondary_backends[name] + + with _lock: + if name not in _secondary_backends: + backend = _build_backend(name) + backend.init() + _secondary_backends[name] = backend + + return _secondary_backends[name] diff --git a/api/src/backend/api/attack_paths/sink/neo4j.py b/api/src/backend/api/attack_paths/sink/neo4j.py new file mode 100644 index 0000000000..c248237f01 --- /dev/null +++ b/api/src/backend/api/attack_paths/sink/neo4j.py @@ -0,0 +1,417 @@ +"""Neo4j sink implementation. + +Owns a Neo4j driver independent from the staging driver. On OSS and local dev +this is the only sink; on hosted deployments it runs only as a legacy read +path while phase-1 drains tenant DBs. +""" + +import atexit +import logging +import threading +import time +from collections.abc import Iterator +from contextlib import AbstractContextManager, contextmanager +from typing import Any + +import neo4j +import neo4j.exceptions +from api.attack_paths.retryable_session import RetryableSession +from api.attack_paths.sink.base import SinkDatabase +from api.attack_paths.sink.drop import ( + NODE_DELETE_QUERY_TEMPLATE, + RELATIONSHIP_DELETE_QUERY_TEMPLATES, + delete_batches, +) +from config.env import env +from django.conf import settings + +logging.getLogger("neo4j").setLevel(logging.ERROR) +logging.getLogger("neo4j").propagate = False + +logger = logging.getLogger(__name__) + +SERVICE_UNAVAILABLE_MAX_RETRIES = env.int( + "ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES", default=3 +) +READ_QUERY_TIMEOUT_SECONDS = env.int( + "ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30 +) +CONN_ACQUISITION_TIMEOUT = env.int("NEO4J_CONN_ACQUISITION_TIMEOUT", default=15) +# TCP connect timeout, ordered below the acquisition timeout so an unreachable +# host can't pin a request or the readiness probe longer than this. +CONNECTION_TIMEOUT = env.int("NEO4J_CONNECTION_TIMEOUT", default=5) +MAX_CONNECTION_LIFETIME = env.int("NEO4J_MAX_CONNECTION_LIFETIME", default=7200) +MAX_CONNECTION_POOL_SIZE = env.int("NEO4J_MAX_CONNECTION_POOL_SIZE", default=50) + +READ_EXCEPTION_CODES = [ + "Neo.ClientError.Statement.AccessMode", + "Neo.ClientError.Procedure.ProcedureNotFound", +] +CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement." +DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound" + + +class Neo4jSink(SinkDatabase): + """Neo4j-backed sink. Multi-database cluster; tenant isolation is physical.""" + + def __init__(self) -> None: + self._driver: neo4j.Driver | None = None + self._lock = threading.Lock() + self._atexit_registered = False + + # Driver + + def _config(self) -> dict: + return settings.DATABASES["neo4j"] + + def _uri(self) -> str: + cfg = self._config() + host = cfg["HOST"] + port = cfg["PORT"] + if not host or not port: + raise RuntimeError( + "NEO4J_HOST / NEO4J_PORT must be set when ATTACK_PATHS_SINK_DATABASE=neo4j" + ) + return f"bolt://{host}:{port}" + + def init(self) -> neo4j.Driver: + if self._driver is not None: + return self._driver + with self._lock: + if self._driver is None: + cfg = self._config() + self._driver = neo4j.GraphDatabase.driver( + self._uri(), + auth=(cfg["USER"], cfg["PASSWORD"]), + keep_alive=True, + max_connection_lifetime=MAX_CONNECTION_LIFETIME, + connection_timeout=CONNECTION_TIMEOUT, + connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT, + max_connection_pool_size=MAX_CONNECTION_POOL_SIZE, + ) + # Eager connectivity check is best-effort: + # A Neo4j that is down at boot must not crash the process, same degradation model as Postgres + # The driver reconnects lazily on first use + # /health/ready surfaces the outage until it recovers + try: + self._driver.verify_connectivity() + + except Exception: + logger.warning( + "Neo4j sink unreachable at init; continuing with a lazily-reconnecting driver", + exc_info=True, + ) + + if not self._atexit_registered: + atexit.register(self.close) + self._atexit_registered = True + return self._driver + + def _get_driver(self) -> neo4j.Driver: + return self.init() + + def verify_connectivity(self) -> None: + self._get_driver().verify_connectivity() + + def close(self) -> None: + with self._lock: + if self._driver is not None: + try: + self._driver.close() + finally: + self._driver = None + + # Sessions + + @contextmanager + def get_session( + self, + database: str | None = None, + default_access_mode: str | None = None, + ) -> Iterator[RetryableSession]: + from api.attack_paths.database import ( + ClientStatementException, + GraphDatabaseQueryException, + WriteQueryNotAllowedException, + ) + + session_wrapper: RetryableSession | None = None + try: + session_wrapper = RetryableSession( + session_factory=lambda: self._get_driver().session( + database=database, default_access_mode=default_access_mode + ), + max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES, + ) + yield session_wrapper + + except neo4j.exceptions.Neo4jError as exc: + if ( + default_access_mode == neo4j.READ_ACCESS + and exc.code + and exc.code in READ_EXCEPTION_CODES + ): + raise WriteQueryNotAllowedException( + message="Read query not allowed", code=READ_EXCEPTION_CODES[0] + ) + + message = exc.message if exc.message is not None else str(exc) + if exc.code and exc.code.startswith(CLIENT_STATEMENT_EXCEPTION_PREFIX): + raise ClientStatementException(message=message, code=exc.code) + raise GraphDatabaseQueryException(message=message, code=exc.code) + + finally: + if session_wrapper is not None: + session_wrapper.close() + + # Operations + + def execute_read_query( + self, + database: str, + cypher: str, + parameters: dict[str, Any] | None = None, + ) -> neo4j.graph.Graph: + with self.get_session( + database, default_access_mode=neo4j.READ_ACCESS + ) as session: + + def _run(tx: neo4j.ManagedTransaction) -> neo4j.graph.Graph: + result = tx.run( + cypher, parameters or {}, timeout=READ_QUERY_TIMEOUT_SECONDS + ) + return result.graph() + + return session.execute_read(_run) + + def create_database(self, database: str) -> None: + with self.get_session() as session: + session.run( + "CREATE DATABASE $database IF NOT EXISTS", {"database": database} + ) + + def drop_database(self, database: str) -> None: + with self.get_session() as session: + session.run(f"DROP DATABASE `{database}` IF EXISTS DESTROY DATA") + + def drop_subgraph(self, database: str, provider_id: str) -> int: + """Delete all nodes for a provider from a tenant database, batched. + + 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. + """ + from api.attack_paths.database import GraphDatabaseQueryException + from tasks.jobs.attack_paths.config import ( + BATCH_SIZE, + PROVIDER_RESOURCE_LABEL, + get_provider_label, + ) + + provider_label = get_provider_label(provider_id) + deleted_nodes = deleted_relationships = 0 + relationship_batches = node_batches = 0 + drop_t0 = time.perf_counter() + + logger.info( + "Dropping provider graph from Neo4j sink database %s " + "(provider=%s, provider_label=%s)", + database, + provider_id, + provider_label, + ) + + try: + logger.info( + "Opening Neo4j sink session for provider graph drop " + "(database=%s, provider=%s)", + database, + provider_id, + ) + with self.get_session(database) as session: + logger.info( + "Opened Neo4j sink session for provider graph drop " + "(database=%s, provider=%s)", + database, + provider_id, + ) + log_target = f"Neo4j sink database {database}" + for ( + phase, + query_template, + ) in RELATIONSHIP_DELETE_QUERY_TEMPLATES.items(): + deleted_relationships, phase_batches = delete_batches( + session=session, + logger=logger, + log_target=log_target, + provider_id=provider_id, + query=query_template.format(provider_label=provider_label), + phase=phase, + count_key="deleted_rels_count", + total_key="rels", + deleted_key="deleted_rels", + initial_total=deleted_relationships, + batch_size=BATCH_SIZE, + drop_t0=drop_t0, + ) + relationship_batches += phase_batches + + deleted_nodes, node_batches = delete_batches( + session=session, + logger=logger, + log_target=log_target, + provider_id=provider_id, + query=NODE_DELETE_QUERY_TEMPLATE.format( + provider_label=provider_label, + provider_resource_label=PROVIDER_RESOURCE_LABEL, + ), + phase="node", + count_key="deleted_nodes_count", + total_key="nodes", + deleted_key="deleted_nodes", + initial_total=0, + batch_size=BATCH_SIZE, + drop_t0=drop_t0, + ) + + except GraphDatabaseQueryException as exc: + if exc.code == DATABASE_NOT_FOUND_CODE: + logger.info( + "Skipped provider graph drop from Neo4j sink database %s " + "(provider=%s, reason=database_not_found, elapsed=%.3fs)", + database, + provider_id, + time.perf_counter() - drop_t0, + ) + return 0 + raise + + logger.info( + "Finished dropping provider graph from Neo4j sink database %s " + "(provider=%s, relationship_batches=%s, deleted_rels=%s, " + "node_batches=%s, deleted_nodes=%s, elapsed=%.3fs)", + database, + provider_id, + relationship_batches, + deleted_relationships, + node_batches, + deleted_nodes, + time.perf_counter() - drop_t0, + ) + return deleted_nodes + + def has_provider_data(self, database: str, provider_id: str) -> bool: + from api.attack_paths.database import GraphDatabaseQueryException + from tasks.jobs.attack_paths.config import ( + PROVIDER_RESOURCE_LABEL, + get_provider_label, + ) + + provider_label = get_provider_label(provider_id) + query = ( + f"MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) RETURN 1 LIMIT 1" + ) + try: + with self.get_session( + database, default_access_mode=neo4j.READ_ACCESS + ) as session: + result = session.run(query) + return result.single() is not None + + except GraphDatabaseQueryException as exc: + if exc.code == DATABASE_NOT_FOUND_CODE: + return False + raise + + def clear_cache(self, database: str) -> None: + from api.attack_paths.database import GraphDatabaseQueryException + + try: + with self.get_session(database) as session: + session.run("CALL db.clearQueryCaches()") + except GraphDatabaseQueryException as exc: + logger.warning( + f"Failed to clear query cache for database `{database}`: {exc}" + ) + + # Sync write path + + def ensure_sync_indexes(self, database: str) -> None: + """Create the `_provider_element_id` lookup index on `_ProviderResource`. + + Every synced node carries the `_ProviderResource` label, so a single + index covers both node-upserts and relationship endpoint MATCHes. + Without this index the rel sync degrades to a label scan per row and + large provider syncs become unworkable. + """ + from tasks.jobs.attack_paths.config import ( + PROVIDER_ELEMENT_ID_PROPERTY, + PROVIDER_RESOURCE_LABEL, + ) + + query = ( + f"CREATE INDEX provider_element_id_idx IF NOT EXISTS " + f"FOR (n:`{PROVIDER_RESOURCE_LABEL}`) " + f"ON (n.`{PROVIDER_ELEMENT_ID_PROPERTY}`)" + ) + with self.get_session(database) as session: + session.run(query).consume() + + def write_nodes( + self, + database: str, + labels: str, + rows: list[dict[str, Any]], + ) -> None: + if not rows: + return + from tasks.jobs.attack_paths.config import ( + PROVIDER_ELEMENT_ID_PROPERTY, + PROVIDER_RESOURCE_LABEL, + ) + + query = f""" + UNWIND $rows AS row + MERGE (n:`{PROVIDER_RESOURCE_LABEL}` {{`{PROVIDER_ELEMENT_ID_PROPERTY}`: row.provider_element_id}}) + SET n:{labels} + SET n += row.props + """ + with self.get_session(database) as session: + session.run(query, {"rows": rows}).consume() + + def write_relationships( + self, + database: str, + rel_type: str, + provider_id: str, + rows: list[dict[str, Any]], + ) -> None: + if not rows: + return + from tasks.jobs.attack_paths.config import ( + PROVIDER_ELEMENT_ID_PROPERTY, + PROVIDER_RESOURCE_LABEL, + get_provider_label, + ) + + provider_label = get_provider_label(provider_id) + query = f""" + UNWIND $rows AS row + MATCH (s:`{PROVIDER_RESOURCE_LABEL}`:`{provider_label}` {{`{PROVIDER_ELEMENT_ID_PROPERTY}`: row.start_element_id}}) + MATCH (t:`{PROVIDER_RESOURCE_LABEL}`:`{provider_label}` {{`{PROVIDER_ELEMENT_ID_PROPERTY}`: row.end_element_id}}) + MERGE (s)-[r:`{rel_type}` {{`{PROVIDER_ELEMENT_ID_PROPERTY}`: row.provider_element_id}}]->(t) + SET r += row.props + """ + with self.get_session(database) as session: + session.run(query, {"rows": rows}).consume() + + # For compatibility with test harnesses that patch the concrete driver + def get_driver(self) -> neo4j.Driver: + return self._get_driver() + + +# Helper for tests / external callers that want a writer session specifically +def get_read_session( + sink: Neo4jSink, database: str +) -> AbstractContextManager[RetryableSession]: + return sink.get_session(database, default_access_mode=neo4j.READ_ACCESS) diff --git a/api/src/backend/api/attack_paths/sink/neptune.py b/api/src/backend/api/attack_paths/sink/neptune.py new file mode 100644 index 0000000000..b0d12069a3 --- /dev/null +++ b/api/src/backend/api/attack_paths/sink/neptune.py @@ -0,0 +1,491 @@ +"""AWS Neptune sink implementation. + +Dual Bolt drivers: one against the writer endpoint for workers, one against +the reader endpoint for the API read path. If `NEPTUNE_READER_ENDPOINT` is +unset the reader falls back to the writer driver so single-node clusters work. + +Neptune is single-database. The `database` argument on the SinkDatabase +protocol is ignored; tenant / provider isolation is enforced by labels that +the sync step already writes on every node (see tasks/jobs/attack_paths/sync.py). + +SigV4 auth lives at the bottom of this file as `neptune_auth_provider`. The +neo4j driver invokes the returned callable on each token refresh. +""" + +import atexit +import datetime +import json +import logging +import threading +import time +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from typing import Any +from urllib.parse import urlsplit + +import neo4j +import neo4j.exceptions +from api.attack_paths.retryable_session import RetryableSession +from api.attack_paths.sink.base import SinkDatabase +from api.attack_paths.sink.drop import ( + NODE_DELETE_QUERY_TEMPLATE, + RELATIONSHIP_DELETE_QUERY_TEMPLATES, + delete_batches, +) +from botocore.auth import SigV4Auth +from botocore.awsrequest import AWSRequest +from botocore.session import Session as BotoSession +from config.env import env +from django.conf import settings +from neo4j.auth_management import AuthManagers, ExpiringAuth + +logging.getLogger("neo4j").setLevel(logging.ERROR) +logging.getLogger("neo4j").propagate = False + +logger = logging.getLogger(__name__) + +SERVICE_UNAVAILABLE_MAX_RETRIES = env.int( + "ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES", default=3 +) +READ_QUERY_TIMEOUT_SECONDS = env.int( + "ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30 +) +# Neptune serverless cold-start can be >30s; give the driver room +CONN_ACQUISITION_TIMEOUT = env.int("NEPTUNE_CONN_ACQUISITION_TIMEOUT", default=60) +# TCP connect timeout, ordered below the acquisition timeout so an unreachable +# endpoint can't pin a request or the readiness probe longer than this. Kept +# generous: cold-start delays query execution, not the socket connect. +CONNECTION_TIMEOUT = env.int("NEPTUNE_CONNECTION_TIMEOUT", default=10) +# Roll connections hourly so SigV4 rotations and cert refreshes don't strand long-lived pool entries +MAX_CONNECTION_LIFETIME = env.int("NEPTUNE_MAX_CONNECTION_LIFETIME", default=3600) +MAX_CONNECTION_POOL_SIZE = env.int("NEPTUNE_MAX_CONNECTION_POOL_SIZE", default=50) + +READ_EXCEPTION_CODES = [ + "Neo.ClientError.Statement.AccessMode", + "Neo.ClientError.Procedure.ProcedureNotFound", +] +CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement." + +# Refresh 60s before the 5-minute SigV4 window closes +SIGV4_TOKEN_LIFETIME_MINUTES = 4 + + +class NeptuneSink(SinkDatabase): + """Neptune-backed sink. Single database; isolation is label-based.""" + + def __init__(self) -> None: + self._writer: neo4j.Driver | None = None + self._reader: neo4j.Driver | None = None + self._lock = threading.Lock() + self._atexit_registered = False + + # Config + + def _config(self) -> dict: + return settings.DATABASES["neptune"] + + def _bolt_uri(self, endpoint: str, port: str) -> str: + return f"bolt+s://{endpoint}:{port}" + + def _https_url(self, endpoint: str, port: str) -> str: + return f"https://{endpoint}:{port}" + + def _build_driver(self, endpoint: str) -> neo4j.Driver: + cfg = self._config() + port = cfg["PORT"] + region = cfg["REGION"] + if not endpoint or not region: + raise RuntimeError( + "NEPTUNE_WRITER_ENDPOINT and AWS_REGION must be set when " + "ATTACK_PATHS_SINK_DATABASE=neptune" + ) + return neo4j.GraphDatabase.driver( + self._bolt_uri(endpoint, port), + auth=AuthManagers.bearer( + neptune_auth_provider(region, self._https_url(endpoint, port)) + ), + keep_alive=True, + max_connection_lifetime=MAX_CONNECTION_LIFETIME, + connection_timeout=CONNECTION_TIMEOUT, + connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT, + max_connection_pool_size=MAX_CONNECTION_POOL_SIZE, + max_transaction_retry_time=0, + ) + + # Lifecycle + + def init(self) -> None: + if self._writer is not None: + return + with self._lock: + if self._writer is None: + cfg = self._config() + writer_endpoint = cfg["WRITER_ENDPOINT"] + reader_endpoint = cfg["READER_ENDPOINT"] or writer_endpoint + + # Eager connectivity checks are best-effort + # A Neptune that is down at boot must not crash the process, same degradation model as Postgres + # Drivers reconnect lazily on first use + # /health/ready surfaces the outage until it recovers + self._writer = self._build_driver(writer_endpoint) + self._verify_best_effort(self._writer, "writer") + + if reader_endpoint == writer_endpoint: + self._reader = self._writer + + else: + self._reader = self._build_driver(reader_endpoint) + self._verify_best_effort(self._reader, "reader") + + if not self._atexit_registered: + atexit.register(self.close) + self._atexit_registered = True + + def close(self) -> None: + with self._lock: + # `Driver.close()` is idempotent, so closing the same driver twice + # (when reader aliases writer on single-endpoint configs) is safe + for driver in (self._reader, self._writer): + if driver is None: + continue + try: + driver.close() + except Exception: # pragma: no cover - best-effort + pass + self._writer = None + self._reader = None + + # Sessions + + def _get_writer(self) -> neo4j.Driver: + self.init() + assert self._writer is not None + return self._writer + + def _get_reader(self) -> neo4j.Driver: + self.init() + assert self._reader is not None + return self._reader + + @staticmethod + def _verify_best_effort(driver: neo4j.Driver, role: str) -> None: + try: + driver.verify_connectivity() + + except Exception: + logger.warning( + "Neptune %s endpoint unreachable at init; continuing with a lazily-reconnecting driver", + role, + exc_info=True, + ) + + def verify_connectivity(self) -> None: + # The API read path uses the reader driver + # On single-endpoint clusters it aliases the writer, so this also covers the writer + # A writer-only outage is a workers' concern (no HTTP probe there) and deliberately does not fail API readiness + self._get_reader().verify_connectivity() + + @contextmanager + def get_session( + self, + database: str | None = None, # noqa: ARG002 - ignored on Neptune + default_access_mode: str | None = None, + ) -> Iterator[RetryableSession]: + from api.attack_paths.database import ( + ClientStatementException, + GraphDatabaseQueryException, + WriteQueryNotAllowedException, + ) + + driver = ( + self._get_reader() + if default_access_mode == neo4j.READ_ACCESS + else self._get_writer() + ) + + session_wrapper: RetryableSession | None = None + try: + session_wrapper = RetryableSession( + session_factory=lambda: driver.session( + default_access_mode=default_access_mode + ), + max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES, + ) + yield session_wrapper + + except neo4j.exceptions.Neo4jError as exc: + if ( + default_access_mode == neo4j.READ_ACCESS + and exc.code + and exc.code in READ_EXCEPTION_CODES + ): + raise WriteQueryNotAllowedException( + message="Read query not allowed", code=READ_EXCEPTION_CODES[0] + ) + + message = exc.message if exc.message is not None else str(exc) + if exc.code and exc.code.startswith(CLIENT_STATEMENT_EXCEPTION_PREFIX): + raise ClientStatementException(message=message, code=exc.code) + raise GraphDatabaseQueryException(message=message, code=exc.code) + + finally: + if session_wrapper is not None: + session_wrapper.close() + + # Operations + + def execute_read_query( + self, + database: str, # noqa: ARG002 - ignored on Neptune + cypher: str, + parameters: dict[str, Any] | None = None, + ) -> neo4j.graph.Graph: + with self.get_session(default_access_mode=neo4j.READ_ACCESS) as session: + + def _run(tx: neo4j.ManagedTransaction) -> neo4j.graph.Graph: + result = tx.run( + cypher, parameters or {}, timeout=READ_QUERY_TIMEOUT_SECONDS + ) + return result.graph() + + return session.execute_read(_run) + + def create_database(self, database: str) -> None: # noqa: ARG002 + # Neptune clusters are single-database; there is nothing to create. + return None + + def drop_database(self, database: str) -> None: # noqa: ARG002 + # Neptune clusters are single-database; there is nothing to drop. + return None + + def drop_subgraph(self, database: str, provider_id: str) -> int: # noqa: ARG002 + """Delete a provider's subgraph in two bounded phases. + + Neptune write transactions are capped at ~2 minutes. A naive + `DETACH DELETE` on a label-scanned batch grows unbounded with graph + density (one node can drag thousands of relationships into the same + transaction). Instead: + + 1. Delete relationships incident to provider nodes, one fixed-size + batch per transaction. + 2. Delete the now-orphaned nodes, one fixed-size batch per transaction. + + Each transaction does work proportional to `batch_size`, never to the + graph's branching factor. + """ + from tasks.jobs.attack_paths.config import ( + BATCH_SIZE, + PROVIDER_RESOURCE_LABEL, + get_provider_label, + ) + + provider_label = get_provider_label(provider_id) + deleted_relationships = 0 + relationship_batches = 0 + node_batches = 0 + drop_t0 = time.perf_counter() + + logger.info( + "Dropping provider graph from Neptune sink " + "(provider=%s, provider_label=%s)", + provider_id, + provider_label, + ) + + logger.info( + "Opening Neptune writer session for provider graph drop (provider=%s)", + provider_id, + ) + with self.get_session() as session: + logger.info( + "Opened Neptune writer session for provider graph drop (provider=%s)", + provider_id, + ) + for phase, query_template in RELATIONSHIP_DELETE_QUERY_TEMPLATES.items(): + deleted_relationships, phase_batches = delete_batches( + session=session, + logger=logger, + log_target="Neptune sink", + provider_id=provider_id, + query=query_template.format(provider_label=provider_label), + phase=phase, + count_key="deleted_rels_count", + total_key="rels", + deleted_key="deleted_rels", + initial_total=deleted_relationships, + batch_size=BATCH_SIZE, + drop_t0=drop_t0, + ) + relationship_batches += phase_batches + + deleted_nodes, node_batches = delete_batches( + session=session, + logger=logger, + log_target="Neptune sink", + provider_id=provider_id, + query=NODE_DELETE_QUERY_TEMPLATE.format( + provider_label=provider_label, + provider_resource_label=PROVIDER_RESOURCE_LABEL, + ), + phase="node", + count_key="deleted_nodes_count", + total_key="nodes", + deleted_key="deleted_nodes", + initial_total=0, + batch_size=BATCH_SIZE, + drop_t0=drop_t0, + ) + + logger.info( + "Finished dropping provider graph from Neptune sink " + "(provider=%s, relationship_batches=%s, deleted_rels=%s, " + "node_batches=%s, deleted_nodes=%s, elapsed=%.3fs)", + provider_id, + relationship_batches, + deleted_relationships, + node_batches, + deleted_nodes, + time.perf_counter() - drop_t0, + ) + return deleted_nodes + + def has_provider_data(self, database: str, provider_id: str) -> bool: # noqa: ARG002 + from tasks.jobs.attack_paths.config import ( + PROVIDER_RESOURCE_LABEL, + get_provider_label, + ) + + provider_label = get_provider_label(provider_id) + query = ( + f"MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) RETURN 1 LIMIT 1" + ) + with self.get_session(default_access_mode=neo4j.READ_ACCESS) as session: + result = session.run(query) + return result.single() is not None + + def clear_cache(self, database: str) -> None: # noqa: ARG002 + # Neptune has no user-facing cache-clear procedure; no-op. + return None + + # Sync write path + + def ensure_sync_indexes(self, database: str) -> None: # noqa: ARG002 + # Neptune routes node and relationship lookups through `~id`, which is the cluster's primary key + # No additional index is needed or supported + return None + + def write_nodes( + self, + database: str, # noqa: ARG002 + labels: str, + rows: list[dict[str, Any]], + ) -> None: + if not rows: + return + from tasks.jobs.attack_paths.config import ( + PROVIDER_ELEMENT_ID_PROPERTY, + PROVIDER_RESOURCE_LABEL, + ) + + # MERGE on `~id` is the documented and engine-optimized idempotent + # upsert pattern for Neptune openCypher. The label inside the MERGE + # matters: Neptune assigns a default `vertex` label to any node + # created without an explicit one, so we pin `_ProviderResource` + # (which every synced node carries anyway) at MERGE-time. Additional + # labels are added after + # + # We also write `_provider_element_id` as a regular property so + # non-sync code (drop_subgraph, query helpers) keeps a stable contract + # that doesn't know about `~id` + query = f""" + UNWIND $rows AS row + MERGE (n:`{PROVIDER_RESOURCE_LABEL}` {{`~id`: row.provider_element_id}}) + SET n:{labels} + SET n += row.props + SET n.`{PROVIDER_ELEMENT_ID_PROPERTY}` = row.provider_element_id + """ + with self.get_session() as session: + session.run(query, {"rows": rows}).consume() + + def write_relationships( + self, + database: str, # noqa: ARG002 + rel_type: str, + provider_id: str, # noqa: ARG002 - encoded in start/end `~id` already + rows: list[dict[str, Any]], + ) -> None: + if not rows: + return + from tasks.jobs.attack_paths.config import PROVIDER_ELEMENT_ID_PROPERTY + + # `id(n) = $value` is Neptune's parameterized fast path; both endpoint + # MATCHes resolve in O(1) via the system `~id`, so per-row work stays + # bounded regardless of batch size + query = f""" + UNWIND $rows AS row + MATCH (s) WHERE id(s) = row.start_element_id + MATCH (e) WHERE id(e) = row.end_element_id + MERGE (s)-[r:`{rel_type}` {{`{PROVIDER_ELEMENT_ID_PROPERTY}`: row.provider_element_id}}]->(e) + SET r += row.props + """ + with self.get_session() as session: + session.run(query, {"rows": rows}).consume() + + # Test helpers + + def get_writer(self) -> neo4j.Driver: + return self._get_writer() + + def get_reader(self) -> neo4j.Driver: + return self._get_reader() + + +# SigV4 auth provider + + +class _NeptuneAuthToken(neo4j.Auth): + """Neo4j Auth backed by a SigV4-signed GET to `/opencypher`.""" + + def __init__(self, region: str, url: str) -> None: + session = BotoSession() + credentials = session.get_credentials() + if credentials is None: + raise RuntimeError( + "No AWS credentials available for Neptune SigV4 signing. " + "Ensure the boto3 credential chain can resolve." + ) + credentials = credentials.get_frozen_credentials() + + request = AWSRequest(method="GET", url=url + "/opencypher") + # SigV4 canonical Host must carry the real `host:port` + # Neptune runs on a non-default port (8182), so `.hostname` would drop it and break signing + request.headers.add_header("Host", urlsplit(url).netloc) + SigV4Auth(credentials, "neptune-db", region).add_auth(request) + + auth_obj = { + header: request.headers[header] + for header in ( + "Authorization", + "X-Amz-Date", + "X-Amz-Security-Token", + "Host", + ) + if header in request.headers + } + auth_obj["HttpMethod"] = "GET" + + super().__init__("basic", "username", json.dumps(auth_obj)) + + +def neptune_auth_provider(region: str, https_url: str) -> Callable[[], ExpiringAuth]: + """Return a callable the neo4j driver can invoke to refresh credentials.""" + + def _provider() -> ExpiringAuth: + token = _NeptuneAuthToken(region, https_url) + expires_at = ( + datetime.datetime.now(datetime.UTC) + + datetime.timedelta(minutes=SIGV4_TOKEN_LIFETIME_MINUTES) + ).timestamp() + return ExpiringAuth(auth=token, expires_at=expires_at) + + return _provider diff --git a/api/src/backend/api/attack_paths/views_helpers.py b/api/src/backend/api/attack_paths/views_helpers.py index bfb077abd0..d1b351f454 100644 --- a/api/src/backend/api/attack_paths/views_helpers.py +++ b/api/src/backend/api/attack_paths/views_helpers.py @@ -5,6 +5,7 @@ from typing import Any import neo4j from api.attack_paths import AttackPathsQueryDefinition from api.attack_paths import database as graph_database +from api.attack_paths import sink as sink_module from api.attack_paths.cypher_sanitizer import ( inject_provider_label, validate_custom_query, @@ -14,7 +15,9 @@ from api.attack_paths.queries.schema import ( RAW_SCHEMA_URL, get_cartography_schema_query, ) +from api.models import AttackPathsScan from config.custom_logging import BackendLogger +from config.env import env from rest_framework.exceptions import APIException, PermissionDenied, ValidationError from tasks.jobs.attack_paths.config import ( INTERNAL_LABELS, @@ -26,6 +29,10 @@ from tasks.jobs.attack_paths.config import ( logger = logging.getLogger(BackendLogger.API) +def _custom_query_timeout_ms() -> int: + return env.int("ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30) * 1000 + + # Predefined query helpers @@ -102,13 +109,13 @@ def execute_query( definition: AttackPathsQueryDefinition, parameters: dict[str, Any], provider_id: str, + scan: AttackPathsScan, ) -> dict[str, Any]: try: - graph = graph_database.execute_read_query( - database=database_name, - cypher=definition.cypher, - parameters=parameters, - ) + # TODO: drop after Neptune cutover + # Route reads by the scan row's recorded sink, not by current settings. + backend = sink_module.get_backend_for_scan(scan) + graph = backend.execute_read_query(database_name, definition.cypher, parameters) return _serialize_graph(graph, provider_id) except graph_database.WriteQueryNotAllowedException: @@ -142,22 +149,31 @@ def execute_custom_query( database_name: str, cypher: str, provider_id: str, + scan: AttackPathsScan, ) -> dict[str, Any]: # Defense-in-depth for custom queries: - # 1. neo4j.READ_ACCESS — prevents mutations at the driver level - # 2. inject_provider_label() — regex-based label injection scopes node patterns - # 3. _serialize_graph() — post-query filter drops nodes without the provider label + # 1. `neo4j.READ_ACCESS` — prevents mutations at the driver level + # 2. `inject_provider_label()` — regex-based label injection scopes node patterns + # 3. `_serialize_graph()` — post-query filter drops nodes without the provider label + # 4. `USING QUERY:TIMEOUTMILLISECONDS` on Neptune — server-side runaway cutoff # # Layer 2 is best-effort (regex can't fully parse Cypher); # layer 3 is the safety net that guarantees provider isolation. validate_custom_query(cypher) cypher = inject_provider_label(cypher, provider_id) + # TODO: drop after Neptune cutover + backend = sink_module.get_backend_for_scan(scan) + + # Neptune enforces a cluster-level query timeout; prepending the hint + # makes the limit explicit and matches the client-side read timeout. + # Applies only when the scan's graph lives in Neptune. + if getattr(scan, "sink_backend", None) == "neptune": + timeout_ms = _custom_query_timeout_ms() + cypher = f"USING QUERY:TIMEOUTMILLISECONDS {timeout_ms}\n{cypher}" + try: - graph = graph_database.execute_read_query( - database=database_name, - cypher=cypher, - ) + graph = backend.execute_read_query(database_name, cypher, None) serialized = _serialize_graph(graph, provider_id) return _truncate_graph(serialized) @@ -180,10 +196,11 @@ def execute_custom_query( def get_cartography_schema( - database_name: str, provider_id: str + database_name: str, provider_id: str, scan: AttackPathsScan ) -> dict[str, str] | None: try: - with graph_database.get_session( + backend = sink_module.get_backend_for_scan(scan) + with backend.get_session( database_name, default_access_mode=neo4j.READ_ACCESS ) as session: result = session.run(get_cartography_schema_query(provider_id)) diff --git a/api/src/backend/api/authentication.py b/api/src/backend/api/authentication.py index 1e9eee46ff..755bd64e39 100644 --- a/api/src/backend/api/authentication.py +++ b/api/src/backend/api/authentication.py @@ -1,11 +1,14 @@ +from math import isfinite from uuid import UUID from api.db_router import MainRouter from api.models import TenantAPIKey, TenantAPIKeyManager from cryptography.fernet import InvalidToken +from django.core.exceptions import ObjectDoesNotExist from django.utils import timezone from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth from drf_simple_apikey.crypto import get_crypto +from drf_simple_apikey.settings import package_settings from rest_framework.authentication import BaseAuthentication from rest_framework.exceptions import AuthenticationFailed from rest_framework.request import Request @@ -21,18 +24,49 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth): def _authenticate_credentials(self, request, key): """ Override to use admin connection, bypassing RLS during authentication. - Delegates to parent after temporarily routing model queries to admin DB. """ - # Temporarily point the model's manager to admin database - original_objects = self.model.objects - self.model.objects = self.model.objects.using(MainRouter.admin_db) + try: + payload = self.key_crypto.decrypt(key) + except ValueError: + raise AuthenticationFailed("Invalid API Key.") + + if not isinstance(payload, dict): + raise AuthenticationFailed("Invalid API Key.") + + payload_pk = payload.get("_pk") + payload_exp = payload.get("_exp") + if ( + not isinstance(payload_pk, str) + or isinstance(payload_exp, bool) + or not isinstance(payload_exp, (int, float)) + or not isfinite(payload_exp) + ): + raise AuthenticationFailed("Invalid API Key.") try: - # Call parent method which will now use admin database - return super()._authenticate_credentials(request, key) - finally: - # Restore original manager - self.model.objects = original_objects + api_key_pk = UUID(payload_pk) + except ValueError: + raise AuthenticationFailed("Invalid API Key.") + + if payload_exp < timezone.now().timestamp(): + raise AuthenticationFailed("API Key has already expired.") + + try: + api_key = self.model.objects.using(MainRouter.admin_db).get(id=api_key_pk) + except ObjectDoesNotExist: + raise AuthenticationFailed("No entity matching this api key.") + + if api_key.revoked: + raise AuthenticationFailed("This API Key has been revoked.") + + client_ip = request.META.get(package_settings.IP_ADDRESS_HEADER) + if api_key.blacklisted_ips and client_ip in api_key.blacklisted_ips: + raise AuthenticationFailed("Access denied from blacklisted IP.") + + if api_key.whitelisted_ips and client_ip not in api_key.whitelisted_ips: + raise AuthenticationFailed("Access restricted to specific IP addresses.") + + return api_key.entity, key def authenticate(self, request: Request): prefixed_key = self.get_key(request) diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index 740556329c..a7f888b453 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -67,6 +67,7 @@ from django_filters.rest_framework import ( ) from rest_framework_json_api.django_filters.backends import DjangoFilterBackend from rest_framework_json_api.serializers import ValidationError +from uuid6 import UUID class CustomDjangoFilterBackend(DjangoFilterBackend): @@ -672,35 +673,32 @@ class LatestResourceFilter(ProviderRelationshipFilterSet): return queryset.filter(tags__text_search=value) -class FindingFilter(CommonFindingFilters): +FINDING_BASE_FILTER_FIELDS = { + "id": ["exact", "in"], + "uid": ["exact", "in"], + "scan": ["exact", "in"], + "delta": ["exact", "in"], + "status": ["exact", "in"], + "severity": ["exact", "in"], + "impact": ["exact", "in"], + "check_id": ["exact", "in", "icontains"], +} + + +class BaseFindingFilter(CommonFindingFilters): + DATE_FILTER_FIELDS = () + DATE_FILTER_NAMES = () + DATE_RANGE_HELP_TEXT = ( + f"Maximum date range is {settings.FINDINGS_MAX_DAYS_IN_RANGE} days." + ) + DATE_FILTER_REQUIRED_DETAIL = "At least one date filter is required." + scan = UUIDFilter(method="filter_scan_id") scan__in = UUIDInFilter(method="filter_scan_id_in") - inserted_at = DateFilter(method="filter_inserted_at", lookup_expr="date") - inserted_at__date = DateFilter(method="filter_inserted_at", lookup_expr="date") - inserted_at__gte = DateFilter( - method="filter_inserted_at_gte", - help_text=f"Maximum date range is {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.", - ) - inserted_at__lte = DateFilter( - method="filter_inserted_at_lte", - help_text=f"Maximum date range is {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.", - ) - class Meta: model = Finding - fields = { - "id": ["exact", "in"], - "uid": ["exact", "in"], - "scan": ["exact", "in"], - "delta": ["exact", "in"], - "status": ["exact", "in"], - "severity": ["exact", "in"], - "impact": ["exact", "in"], - "check_id": ["exact", "in", "icontains"], - "inserted_at": ["date", "gte", "lte"], - "updated_at": ["gte", "lte"], - } + fields = FINDING_BASE_FILTER_FIELDS filter_overrides = { FindingDeltaEnumField: { "filter_class": CharFilter, @@ -723,17 +721,13 @@ class FindingFilter(CommonFindingFilters): return queryset.filter(resource_services__contains=[value]) def filter_queryset(self, queryset): - if not (self.data.get("scan") or self.data.get("scan__in")) and not ( - self.data.get("inserted_at") - or self.data.get("inserted_at__date") - or self.data.get("inserted_at__gte") - or self.data.get("inserted_at__lte") + if not (self.data.get("scan") or self.data.get("scan__in")) and not any( + self.data.get(filter_name) for filter_name in self.DATE_FILTER_NAMES ): raise ValidationError( [ { - "detail": "At least one date filter is required: filter[inserted_at], filter[inserted_at.gte], " - "or filter[inserted_at.lte].", + "detail": self.DATE_FILTER_REQUIRED_DETAIL, "status": 400, "source": {"pointer": "/data/attributes/inserted_at"}, "code": "required", @@ -742,31 +736,42 @@ class FindingFilter(CommonFindingFilters): ) cleaned = self.form.cleaned_data - exact_date = cleaned.get("inserted_at") or cleaned.get("inserted_at__date") - gte_date = cleaned.get("inserted_at__gte") or exact_date - lte_date = cleaned.get("inserted_at__lte") or exact_date - - if gte_date is None: - gte_date = datetime.now(UTC).date() - if lte_date is None: - lte_date = datetime.now(UTC).date() - - if abs(lte_date - gte_date) > timedelta( - days=settings.FINDINGS_MAX_DAYS_IN_RANGE - ): - raise ValidationError( - [ - { - "detail": f"The date range cannot exceed {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.", - "status": 400, - "source": {"pointer": "/data/attributes/inserted_at"}, - "code": "invalid", - } - ] - ) + for field_name in self.DATE_FILTER_FIELDS: + self.validate_datetime_filter_range(cleaned, field_name) return super().filter_queryset(queryset) + def validate_datetime_filter_range(self, cleaned, field_name): + exact_value = cleaned.get(field_name) or cleaned.get(f"{field_name}__date") + gte_value = cleaned.get(f"{field_name}__gte") or exact_value + lte_value = cleaned.get(f"{field_name}__lte") or exact_value + + if not (exact_value or gte_value or lte_value): + return + + default_value = datetime.now(UTC).date() + gte_value = gte_value or default_value + lte_value = lte_value or default_value + + gte_datetime = self.filter_value_to_datetime(gte_value, field_name) + lte_datetime = self.filter_value_to_datetime(lte_value, field_name) + + if abs(lte_datetime - gte_datetime) <= timedelta( + days=settings.FINDINGS_MAX_DAYS_IN_RANGE + ): + return + + raise ValidationError( + [ + { + "detail": f"The date range cannot exceed {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.", + "status": 400, + "source": {"pointer": f"/data/attributes/{field_name}"}, + "code": "invalid", + } + ] + ) + # Convert filter values to UUIDv7 values for use with partitioning def filter_scan_id(self, queryset, name, value): try: @@ -824,27 +829,169 @@ class FindingFilter(CommonFindingFilters): datetime_value = self.maybe_date_to_datetime(value) start = uuid7_start(datetime_to_uuid7(datetime_value)) end = uuid7_start(datetime_to_uuid7(datetime_value + timedelta(days=1))) - return queryset.filter(id__gte=start, id__lt=end) def filter_inserted_at_gte(self, queryset, name, value): datetime_value = self.maybe_date_to_datetime(value) start = uuid7_start(datetime_to_uuid7(datetime_value)) - return queryset.filter(id__gte=start) def filter_inserted_at_lte(self, queryset, name, value): datetime_value = self.maybe_date_to_datetime(value) end = uuid7_start(datetime_to_uuid7(datetime_value + timedelta(days=1))) - return queryset.filter(id__lt=end) @staticmethod def maybe_date_to_datetime(value): - dt = value + if isinstance(value, datetime): + return value if isinstance(value, date): - dt = datetime.combine(value, datetime.min.time(), tzinfo=UTC) - return dt + return datetime.combine(value, datetime.min.time(), tzinfo=UTC) + if isinstance(value, str): + return parse(value) + return value + + @classmethod + def filter_value_to_datetime(cls, value, field_name): + try: + datetime_value = cls.maybe_date_to_datetime(value) + except (TypeError, ValueError, OverflowError): + raise ValidationError( + [ + { + "detail": "Enter a valid date or datetime.", + "status": 400, + "source": {"pointer": f"/data/attributes/{field_name}"}, + "code": "invalid", + } + ] + ) + + if datetime_value.tzinfo is None: + return datetime_value.replace(tzinfo=UTC) + return datetime_value.astimezone(UTC) + + +class FindingFilter(BaseFindingFilter): + DATE_FILTER_FIELDS = ("inserted_at", "updated_at") + DATE_FILTER_NAMES = ( + "inserted_at", + "inserted_at__date", + "inserted_at__gte", + "inserted_at__lte", + "updated_at", + "updated_at__date", + "updated_at__gte", + "updated_at__lte", + ) + DATE_FILTER_REQUIRED_DETAIL = ( + "At least one date filter is required: filter[inserted_at], filter[updated_at], " + "filter[inserted_at.gte], filter[updated_at.gte], filter[inserted_at.lte], " + "or filter[updated_at.lte]." + ) + + inserted_at = CharFilter(method="filter_inserted_at") + inserted_at__date = DateFilter(method="filter_inserted_at", lookup_expr="date") + inserted_at__gte = CharFilter( + method="filter_inserted_at", + help_text=BaseFindingFilter.DATE_RANGE_HELP_TEXT, + ) + inserted_at__lte = CharFilter( + method="filter_inserted_at", + help_text=BaseFindingFilter.DATE_RANGE_HELP_TEXT, + ) + updated_at = CharFilter(method="filter_updated_at") + updated_at__date = DateFilter(method="filter_updated_at", lookup_expr="date") + updated_at__gte = CharFilter( + method="filter_updated_at", + help_text=BaseFindingFilter.DATE_RANGE_HELP_TEXT, + ) + updated_at__lte = CharFilter( + method="filter_updated_at", + help_text=BaseFindingFilter.DATE_RANGE_HELP_TEXT, + ) + + class Meta(BaseFindingFilter.Meta): + fields = FINDING_BASE_FILTER_FIELDS | { + "inserted_at": ["date", "gte", "lte"], + "updated_at": ["date", "gte", "lte"], + } + + def filter_inserted_at(self, queryset, name, value): + start, end = self.filter_value_to_datetime_bounds(value, "inserted_at") + + if name.endswith("__gte"): + return queryset.filter(id__gte=self.datetime_to_uuid7_boundary(start)) + if name.endswith("__lte"): + return queryset.filter(id__lt=self.datetime_to_uuid7_boundary(end)) + + return queryset.filter( + id__gte=self.datetime_to_uuid7_boundary(start), + id__lt=self.datetime_to_uuid7_boundary(end), + ) + + def filter_updated_at(self, queryset, name, value): + start, end = self.filter_value_to_datetime_bounds(value, "updated_at") + + if name.endswith("__gte"): + return queryset.filter(updated_at__gte=start) + if name.endswith("__lte"): + return queryset.filter(updated_at__lt=end) + + return queryset.filter(updated_at__gte=start, updated_at__lt=end) + + @classmethod + def filter_value_to_datetime_bounds(cls, value, field_name): + start = cls.filter_value_to_datetime(value, field_name) + if cls.is_date_filter_value(value): + return start, start + timedelta(days=1) + return start, start + timedelta(milliseconds=1) + + @staticmethod + def datetime_to_uuid7_boundary(datetime_value): + timestamp_ms = int(datetime_value.timestamp() * 1000) & 0xFFFFFFFFFFFF + uuid_int = timestamp_ms << 80 + uuid_int |= 0x7 << 76 + uuid_int |= 0x2 << 62 + return UUID(int=uuid_int) + + @staticmethod + def is_date_filter_value(value): + if isinstance(value, datetime): + return False + if isinstance(value, date): + return True + return isinstance(value, str) and len(value.strip()) == 10 + + +class FindingMetadataFilter(BaseFindingFilter): + DATE_FILTER_FIELDS = ("inserted_at",) + DATE_FILTER_NAMES = ( + "inserted_at", + "inserted_at__date", + "inserted_at__gte", + "inserted_at__lte", + ) + DATE_FILTER_REQUIRED_DETAIL = ( + "At least one date filter is required: filter[inserted_at], filter[inserted_at.gte], " + "or filter[inserted_at.lte]." + ) + + inserted_at = DateFilter(method="filter_inserted_at", lookup_expr="date") + inserted_at__date = DateFilter(method="filter_inserted_at", lookup_expr="date") + inserted_at__gte = DateFilter( + method="filter_inserted_at_gte", + help_text=BaseFindingFilter.DATE_RANGE_HELP_TEXT, + ) + inserted_at__lte = DateFilter( + method="filter_inserted_at_lte", + help_text=BaseFindingFilter.DATE_RANGE_HELP_TEXT, + ) + + class Meta(BaseFindingFilter.Meta): + fields = FINDING_BASE_FILTER_FIELDS | { + "inserted_at": ["date", "gte", "lte"], + } class LatestFindingFilter(CommonFindingFilters): diff --git a/api/src/backend/api/health.py b/api/src/backend/api/health.py index 691640c0bd..cca3bcef72 100644 --- a/api/src/backend/api/health.py +++ b/api/src/backend/api/health.py @@ -2,8 +2,9 @@ Format (draft-inadarei-api-health-check-06). Liveness reports only process status. Readiness verifies that PostgreSQL, -Valkey and Neo4j are reachable and returns per-dependency detail when any -of them is unreachable. +Valkey and the attack-paths graph store (Neo4j or Neptune, per +``ATTACK_PATHS_SINK_DATABASE``) are reachable and returns per-dependency +detail when any of them is unreachable. """ from __future__ import annotations @@ -11,6 +12,8 @@ from __future__ import annotations import logging import threading import time +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as FuturesTimeoutError from contextlib import suppress from datetime import UTC, datetime from typing import Any @@ -37,9 +40,28 @@ STATUS_FAIL = "fail" STATUS_WARN = "warn" # Short socket timeout so a stuck Valkey cannot stall the probe. -# Neo4j inherits its driver-level ``connection_acquisition_timeout``. VALKEY_PROBE_TIMEOUT_SECONDS = 2 +# Probe-scoped budget for the graph database. +# ``Driver.verify_connectivity()`` takes no timeout; its only bound is the +# driver-level ``connection_acquisition_timeout`` (60s on Neptune). The +# probe needs its own budget, independent of the workload driver, so a +# graph-database outage cannot pin a worker thread (and the readiness lock) +# for a minute. +GRAPH_DB_PROBE_TIMEOUT_SECONDS = 5 + +# Bounded pool that enforces ``GRAPH_DB_PROBE_TIMEOUT_SECONDS``. If the +# graph database is unreachable the probe call blocks until the driver's +# own acquisition timeout fires; we abandon the future after the budget and +# report ``fail``. Orphaned tasks are capped by ``max_workers`` plus the 3s +# readiness cache plus the per-IP throttle, so they cannot pile up: worst +# case during a graph-database outage is every readiness call failing fast +# in ``GRAPH_DB_PROBE_TIMEOUT_SECONDS`` with at most 2 background threads +# stuck for <= the driver acquisition timeout. +_graph_db_probe_executor = ThreadPoolExecutor( + max_workers=2, thread_name_prefix="health-graph-db-probe" +) + # Brief cache window so high-frequency probes (ALB target groups, scrapers) # do not stampede the actual dependency checks. CACHE_CONTROL_HEADER = "max-age=3, must-revalidate" @@ -109,11 +131,24 @@ def _probe_valkey() -> None: client.close() -def _probe_neo4j() -> None: - # Lazy import: avoids pulling attack_paths into the boot import graph. - from api.attack_paths.database import get_driver +def _graph_db_component_id() -> str: + """Return the active graph database name for the ``componentId`` field.""" + return settings.ATTACK_PATHS_SINK_DATABASE.strip().lower() - get_driver().verify_connectivity() + +def _probe_graph_db() -> None: + # Lazy import: avoids pulling attack_paths into the boot import graph + from api.attack_paths.database import verify_connectivity + + future = _graph_db_probe_executor.submit(verify_connectivity) + try: + future.result(timeout=GRAPH_DB_PROBE_TIMEOUT_SECONDS) + except FuturesTimeoutError as exc: + # Do not wait for the abandoned task; it ends when the driver's own acquisition timeout fires + future.cancel() + raise TimeoutError( + f"graph-db probe exceeded {GRAPH_DB_PROBE_TIMEOUT_SECONDS}s" + ) from exc def _build_check_entry( @@ -176,14 +211,18 @@ def _readiness_payload() -> tuple[dict[str, Any], int]: ): return snapshot[1], snapshot[2] + graph_db_component_id = _graph_db_component_id() + postgres_result, postgres_ms = _measure("postgres", _probe_postgres) valkey_result, valkey_ms = _measure("valkey", _probe_valkey) - neo4j_result, neo4j_ms = _measure("neo4j", _probe_neo4j) + graph_db_result, graph_db_ms = _measure(graph_db_component_id, _probe_graph_db) entries = [ _build_check_entry("postgres", "datastore", postgres_result, postgres_ms), _build_check_entry("valkey", "datastore", valkey_result, valkey_ms), - _build_check_entry("neo4j", "datastore", neo4j_result, neo4j_ms), + _build_check_entry( + graph_db_component_id, "datastore", graph_db_result, graph_db_ms + ), ] overall = _aggregate_status(entries) @@ -191,7 +230,7 @@ def _readiness_payload() -> tuple[dict[str, Any], int]: payload["checks"] = { "postgres:responseTime": [entries[0]], "valkey:responseTime": [entries[1]], - "neo4j:responseTime": [entries[2]], + "graphdb:responseTime": [entries[2]], } http_status = ( @@ -233,10 +272,10 @@ class LivenessView(APIView): class ReadinessView(APIView): """Readiness probe. - Returns 200 when PostgreSQL, Valkey and Neo4j all respond, or 503 with - per-dependency detail when any of them is unreachable. Per-IP throttle - plus the short in-process result cache cap the real dependency hits - regardless of inbound traffic shape. + Returns 200 when PostgreSQL, Valkey and the attack-paths graph store + all respond, or 503 with per-dependency detail when any of them is + unreachable. Per-IP throttle plus the short in-process result cache cap + the real dependency hits regardless of inbound traffic shape. """ authentication_classes: list = [] diff --git a/api/src/backend/api/migrations/0096_attack_paths_scan_is_migrated.py b/api/src/backend/api/migrations/0096_attack_paths_scan_is_migrated.py new file mode 100644 index 0000000000..75b3e2cac7 --- /dev/null +++ b/api/src/backend/api/migrations/0096_attack_paths_scan_is_migrated.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0095_reconcile_orphan_tasks_periodic_task"), + ] + + operations = [ + migrations.AddField( + model_name="attackpathsscan", + name="is_migrated", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="attackpathsscan", + name="sink_backend", + field=models.CharField( + choices=[("neo4j", "Neo4j"), ("neptune", "Neptune")], + default="neo4j", + max_length=16, + ), + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 47f9803b95..c2beba97b4 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -757,6 +757,10 @@ class Scan(RowLevelSecurityProtectedModel): class AttackPathsScan(RowLevelSecurityProtectedModel): + class SinkBackendChoices(models.TextChoices): + NEO4J = "neo4j", "Neo4j" + NEPTUNE = "neptune", "Neptune" + objects = ActiveProviderManager() all_objects = models.Manager() @@ -805,6 +809,18 @@ class AttackPathsScan(RowLevelSecurityProtectedModel): ) ingestion_exceptions = models.JSONField(default=dict, null=True, blank=True) + # True when the scan was synced with the current schema (list-typed + # properties materialised as child item nodes). False for pre-cutover scans + # still using the previous graph shape. Query catalog selection uses this + # flag; physical read routing uses sink_backend below. + # TODO: drop after Neptune cutover + is_migrated = models.BooleanField(default=False) + sink_backend = models.CharField( + choices=SinkBackendChoices.choices, + default=SinkBackendChoices.NEO4J, + max_length=16, + ) + class Meta(RowLevelSecurityProtectedModel.Meta): db_table = "attack_paths_scans" diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 767a3aaf78..4bf755a258 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.33.0 + version: 1.34.0 description: |- Prowler API specification. @@ -9,7 +9,7 @@ info: paths: /api/v1/api-keys: get: - operationId: api_keys_list + operationId: api_v1_api_keys_list description: Retrieve a list of API keys for the tenant, with filtering support. summary: List API keys parameters: @@ -141,7 +141,7 @@ paths: $ref: '#/components/schemas/PaginatedTenantApiKeyList' description: '' post: - operationId: api_keys_create + operationId: api_v1_api_keys_create description: Create a new API key for the tenant. summary: Create a new API key tags: @@ -169,7 +169,7 @@ paths: description: '' /api/v1/api-keys/{id}: get: - operationId: api_keys_retrieve + operationId: api_v1_api_keys_retrieve description: Fetch detailed information about a specific API key by its ID. summary: Retrieve API key details parameters: @@ -220,7 +220,7 @@ paths: $ref: '#/components/schemas/TenantApiKeyResponse' description: '' patch: - operationId: api_keys_partial_update + operationId: api_v1_api_keys_partial_update description: Modify certain fields of an existing API key without affecting other settings. summary: Partially update an API key @@ -257,7 +257,7 @@ paths: description: '' /api/v1/api-keys/{id}/revoke: delete: - operationId: api_keys_revoke_destroy + operationId: api_v1_api_keys_revoke_destroy description: Revoke an API key by its ID. This action is irreversible and will prevent the key from being used. summary: Revoke an API key @@ -282,7 +282,7 @@ paths: description: API key was successfully revoked /api/v1/attack-paths-scans: get: - operationId: attack_paths_scans_list + operationId: api_v1_attack_paths_scans_list description: Retrieve Attack Paths scans for the tenant with support for filtering, ordering, and pagination. summary: List Attack Paths scans @@ -352,11 +352,26 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -370,10 +385,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -397,7 +412,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -411,10 +426,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -575,7 +590,7 @@ paths: description: '' /api/v1/attack-paths-scans/{id}: get: - operationId: attack_paths_scans_retrieve + operationId: api_v1_attack_paths_scans_retrieve description: Fetch full details for a specific Attack Paths scan. summary: Retrieve Attack Paths scan details parameters: @@ -635,7 +650,7 @@ paths: description: '' /api/v1/attack-paths-scans/{id}/queries: get: - operationId: attack_paths_scans_queries_retrieve + operationId: api_v1_attack_paths_scans_queries_retrieve description: Retrieve the catalog of Attack Paths queries available for this Attack Paths scan. summary: List Attack Paths queries @@ -698,7 +713,7 @@ paths: description: No queries found for the selected provider /api/v1/attack-paths-scans/{id}/queries/custom: post: - operationId: attack_paths_scans_queries_custom_create + operationId: api_v1_attack_paths_scans_queries_custom_create description: Execute a raw openCypher query against the Attack Paths graph. Results are filtered to the scan's provider and truncated to a maximum node count. @@ -745,7 +760,7 @@ paths: description: Query execution failed due to a database error /api/v1/attack-paths-scans/{id}/queries/run: post: - operationId: attack_paths_scans_queries_run_create + operationId: api_v1_attack_paths_scans_queries_run_create description: Execute the selected Attack Paths query against the Attack Paths graph and return the resulting subgraph. summary: Execute an Attack Paths query @@ -792,7 +807,7 @@ paths: description: Attack Paths query execution failed due to a database error /api/v1/attack-paths-scans/{id}/schema: get: - operationId: attack_paths_scans_schema_retrieve + operationId: api_v1_attack_paths_scans_schema_retrieve description: Return the cartography provider, version, and links to the schema documentation for the cloud provider associated with this Attack Paths scan. summary: Retrieve cartography schema metadata @@ -839,9 +854,11 @@ paths: description: Unable to retrieve cartography schema due to a database error /api/v1/compliance-overviews: get: - operationId: compliance_overviews_list - description: Retrieve an overview of all the compliance in a given scan. - summary: List compliance overviews for a scan + operationId: api_v1_compliance_overviews_list + description: Retrieve compliance overview data for a scan. When provider filters + are provided, the endpoint uses the latest completed scan for each matching + provider. + summary: List compliance overviews parameters: - in: query name: fields[compliance-overviews] @@ -900,6 +917,120 @@ paths: schema: type: string format: date-time + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + x-spec-enum-id: 203afc16daac9b64 + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - okta + - openstack + - oraclecloud + - vercel + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + x-spec-enum-id: 203afc16daac9b64 + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - okta + - openstack + - oraclecloud + - vercel + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + explode: false + style: form - in: query name: filter[region] schema: @@ -922,8 +1053,7 @@ paths: schema: type: string format: uuid - description: Related scan ID. - required: true + description: Related scan ID. Required unless a provider filter is provided. - name: filter[search] required: false in: query @@ -998,7 +1128,7 @@ paths: description: Compliance overviews generation task failed /api/v1/compliance-overviews/attributes: get: - operationId: compliance_overviews_attributes_retrieve + operationId: api_v1_compliance_overviews_attributes_retrieve description: Retrieve detailed attribute information for all requirements in a specific compliance framework along with the associated check IDs for each requirement. @@ -1028,6 +1158,14 @@ paths: type: string description: Compliance framework ID to get attributes for. required: true + - in: query + name: filter[scan_id] + schema: + type: string + format: uuid + 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. tags: - Compliance Overview security: @@ -1041,9 +1179,10 @@ paths: description: Compliance attributes obtained successfully /api/v1/compliance-overviews/metadata: get: - operationId: compliance_overviews_metadata_retrieve - description: Fetch unique metadata values from a set of compliance overviews. - This is useful for dynamic filtering. + operationId: api_v1_compliance_overviews_metadata_retrieve + 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. summary: Retrieve metadata values from compliance overviews parameters: - in: query @@ -1057,13 +1196,209 @@ paths: description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[compliance_id] + schema: + type: string + - in: query + name: filter[compliance_id__icontains] + schema: + type: string + - in: query + name: filter[framework] + schema: + type: string + - in: query + name: filter[framework__icontains] + schema: + type: string + - in: query + name: filter[framework__iexact] + schema: + type: string + - in: query + name: filter[inserted_at] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__date] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[inserted_at__lte] + schema: + type: string + format: date-time + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + x-spec-enum-id: 203afc16daac9b64 + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - okta + - openstack + - oraclecloud + - vercel + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + x-spec-enum-id: 203afc16daac9b64 + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - okta + - openstack + - oraclecloud + - vercel + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + explode: false + style: form + - in: query + name: filter[region] + schema: + type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[scan_id] schema: type: string format: uuid - description: Related scan ID. - required: true + description: Related scan ID. Required unless a provider filter is provided. + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: filter[version] + schema: + type: string + - in: query + name: filter[version__icontains] + schema: + type: string + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - compliance_id + - -compliance_id + explode: false tags: - Compliance Overview security: @@ -1100,11 +1435,12 @@ paths: description: Compliance overviews generation task failed /api/v1/compliance-overviews/requirements: get: - operationId: compliance_overviews_requirements_retrieve - 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 for a scan + operationId: api_v1_compliance_overviews_requirements_retrieve + 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. + summary: List compliance requirements overview parameters: - in: query name: fields[compliance-requirements-details] @@ -1163,6 +1499,120 @@ paths: schema: type: string format: date-time + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + x-spec-enum-id: 203afc16daac9b64 + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - okta + - openstack + - oraclecloud + - vercel + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + x-spec-enum-id: 203afc16daac9b64 + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - okta + - openstack + - oraclecloud + - vercel + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + explode: false + style: form - in: query name: filter[region] schema: @@ -1185,8 +1635,7 @@ paths: schema: type: string format: uuid - description: Related scan ID. - required: true + description: Related scan ID. Required unless a provider filter is provided. - name: filter[search] required: false in: query @@ -1249,7 +1698,7 @@ paths: description: Compliance overviews generation task failed /api/v1/finding-groups: get: - operationId: finding_groups_list + operationId: api_v1_finding_groups_list description: "\n Retrieve aggregated findings grouped by check_id.\n\n\ \ Each group shows:\n - Aggregated status (FAIL if any non-muted\ \ failure)\n - Maximum severity across all findings\n - Resource\ @@ -1422,6 +1871,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -1454,10 +1918,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -1494,10 +1958,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -1799,7 +2263,7 @@ paths: description: '' /api/v1/finding-groups/{id}/resources: get: - operationId: finding_groups_resources_retrieve + operationId: api_v1_finding_groups_resources_retrieve description: "\n Retrieve resources affected by a specific check (finding\ \ group).\n\n Returns individual resources with their current status,\ \ severity,\n and timing information including how long they have been\ @@ -1970,6 +2434,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -2002,10 +2481,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -2042,10 +2521,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -2342,12 +2821,12 @@ paths: description: '' /api/v1/finding-groups/latest: get: - operationId: finding_groups_latest_retrieve + operationId: api_v1_finding_groups_latest_retrieve description: "\n Retrieve the latest available state for each finding\ \ group (check_id).\n\n This endpoint returns finding groups without\ \ requiring date filters,\n automatically using the latest available\ - \ data per check_id.\n All other filters (provider_id, provider_type,\ - \ check_id) are still supported.\n " + \ data per check_id.\n Provider, provider group, check, and computed\ + \ filters are still supported.\n " summary: List latest finding groups parameters: - in: query @@ -2394,6 +2873,471 @@ paths: description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[category] + schema: + type: string + - in: query + name: filter[category__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[check_id] + schema: + type: string + - in: query + name: filter[check_id__icontains] + schema: + type: string + - in: query + name: filter[check_id__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[check_title__icontains] + schema: + type: string + - in: query + name: filter[delta] + schema: + type: string + enum: + - changed + - new + description: |- + * `new` - New + * `changed` - Changed + - in: query + name: filter[impact] + schema: + type: string + enum: + - critical + - high + - informational + - low + - medium + description: |- + * `critical` - Critical + * `high` - High + * `medium` - Medium + * `low` - Low + * `informational` - Informational + - in: query + name: filter[muted] + schema: + type: boolean + description: If this filter is not provided, muted and non-muted findings + will be returned. + - in: query + name: filter[provider] + schema: + type: string + format: uuid + - in: query + name: filter[provider__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_alias] + schema: + type: string + - in: query + name: filter[provider_alias__icontains] + schema: + type: string + - in: query + name: filter[provider_alias__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - okta + - openstack + - oraclecloud + - vercel + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - okta + - openstack + - oraclecloud + - vercel + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + explode: false + style: form + - in: query + name: filter[provider_uid] + schema: + type: string + - in: query + name: filter[provider_uid__icontains] + schema: + type: string + - in: query + name: filter[provider_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[region] + schema: + type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_groups] + schema: + type: string + - in: query + name: filter[resource_groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_name] + schema: + type: string + - in: query + name: filter[resource_name__icontains] + schema: + type: string + - in: query + name: filter[resource_name__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_type] + schema: + type: string + - in: query + name: filter[resource_type__icontains] + schema: + type: string + - in: query + name: filter[resource_type__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_uid] + schema: + type: string + - in: query + name: filter[resource_uid__icontains] + schema: + type: string + - in: query + name: filter[resource_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resources] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[scan] + schema: + type: string + format: uuid + - in: query + name: filter[scan__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[service] + schema: + type: string + - in: query + name: filter[service__icontains] + schema: + type: string + - in: query + name: filter[service__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[severity] + schema: + type: string + enum: + - critical + - high + - informational + - low + - medium + description: |- + * `critical` - Critical + * `high` - High + * `medium` - Medium + * `low` - Low + * `informational` - Informational + - in: query + name: filter[status] + schema: + type: string + enum: + - FAIL + - MANUAL + - PASS + description: |- + * `FAIL` - Fail + * `PASS` - Pass + * `MANUAL` - Manual + - in: query + name: filter[uid] + schema: + type: string + - in: query + name: filter[updated_at] + schema: + type: string + format: date + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - id + - -id + - check_id + - -check_id + - check_title + - -check_title + - check_description + - -check_description + - severity + - -severity + - status + - -status + - muted + - -muted + - impacted_providers + - -impacted_providers + - resources_fail + - -resources_fail + - resources_total + - -resources_total + - pass_count + - -pass_count + - fail_count + - -fail_count + - manual_count + - -manual_count + - pass_muted_count + - -pass_muted_count + - fail_muted_count + - -fail_muted_count + - manual_muted_count + - -manual_muted_count + - muted_count + - -muted_count + - new_count + - -new_count + - changed_count + - -changed_count + - new_fail_count + - -new_fail_count + - new_fail_muted_count + - -new_fail_muted_count + - new_pass_count + - -new_pass_count + - new_pass_muted_count + - -new_pass_muted_count + - new_manual_count + - -new_manual_count + - new_manual_muted_count + - -new_manual_muted_count + - changed_fail_count + - -changed_fail_count + - changed_fail_muted_count + - -changed_fail_muted_count + - changed_pass_count + - -changed_pass_count + - changed_pass_muted_count + - -changed_pass_muted_count + - changed_manual_count + - -changed_manual_count + - changed_manual_muted_count + - -changed_manual_muted_count + - first_seen_at + - -first_seen_at + - last_seen_at + - -last_seen_at + - failing_since + - -failing_since + explode: false tags: - Finding Groups security: @@ -2407,7 +3351,7 @@ paths: description: '' /api/v1/finding-groups/latest/{check_id}/resources: get: - operationId: finding_groups_latest_resources_retrieve + operationId: api_v1_finding_groups_latest_resources_retrieve description: "\n Retrieve resources affected by a specific check (finding\ \ group) from the\n latest completed scan for each provider.\n\n \ \ Returns individual resources with their current status, severity,\n\ @@ -2561,6 +3505,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -2593,10 +3552,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -2633,10 +3592,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -2926,7 +3885,7 @@ paths: description: '' /api/v1/findings: get: - operationId: findings_list + operationId: api_v1_findings_list description: Retrieve a list of all findings with options for filtering by various criteria. summary: List all findings @@ -3068,13 +4027,11 @@ paths: name: filter[inserted_at__gte] schema: type: string - format: date description: Maximum date range is 7 days. - in: query name: filter[inserted_at__lte] schema: type: string - format: date description: Maximum date range is 7 days. - in: query name: filter[muted] @@ -3114,6 +4071,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -3133,7 +4105,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -3147,10 +4119,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -3174,7 +4146,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -3188,10 +4160,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -3422,6 +4394,10 @@ paths: style: form - in: query name: filter[updated_at] + schema: + type: string + - in: query + name: filter[updated_at__date] schema: type: string format: date @@ -3429,12 +4405,12 @@ paths: name: filter[updated_at__gte] schema: type: string - format: date-time + description: Maximum date range is 7 days. - in: query name: filter[updated_at__lte] schema: type: string - format: date-time + description: Maximum date range is 7 days. - in: query name: include schema: @@ -3492,7 +4468,7 @@ paths: description: '' /api/v1/findings/{id}: get: - operationId: findings_retrieve + operationId: api_v1_findings_retrieve description: Fetch detailed information about a specific finding by its ID. summary: Retrieve data from a specific finding parameters: @@ -3556,7 +4532,7 @@ paths: description: '' /api/v1/findings/findings_services_regions: get: - operationId: findings_findings_services_regions_retrieve + operationId: api_v1_findings_findings_services_regions_retrieve description: Fetch services and regions affected in findings. summary: Retrieve the services and regions that are impacted by findings parameters: @@ -3668,7 +4644,6 @@ paths: name: filter[inserted_at] schema: type: string - format: date - in: query name: filter[inserted_at__date] schema: @@ -3678,13 +4653,11 @@ paths: name: filter[inserted_at__gte] schema: type: string - format: date description: Maximum date range is 7 days. - in: query name: filter[inserted_at__lte] schema: type: string - format: date description: Maximum date range is 7 days. - in: query name: filter[muted] @@ -3724,6 +4697,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -3743,7 +4731,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -3757,10 +4745,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -3784,7 +4772,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -3798,10 +4786,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -4032,6 +5020,10 @@ paths: style: form - in: query name: filter[updated_at] + schema: + type: string + - in: query + name: filter[updated_at__date] schema: type: string format: date @@ -4039,12 +5031,12 @@ paths: name: filter[updated_at__gte] schema: type: string - format: date-time + description: Maximum date range is 7 days. - in: query name: filter[updated_at__lte] schema: type: string - format: date-time + description: Maximum date range is 7 days. - name: sort required: false in: query @@ -4079,7 +5071,7 @@ paths: description: '' /api/v1/findings/latest: get: - operationId: findings_latest_retrieve + operationId: api_v1_findings_latest_retrieve description: Retrieve a list of the latest findings from the latest scans for each provider with options for filtering by various criteria. summary: List the latest findings @@ -4242,6 +5234,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -4261,7 +5268,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -4275,10 +5282,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -4302,7 +5309,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -4316,10 +5323,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -4583,7 +5590,7 @@ paths: description: '' /api/v1/findings/metadata: get: - operationId: findings_metadata_retrieve + operationId: api_v1_findings_metadata_retrieve description: Fetch unique metadata values from a set of findings. This is useful for dynamic filtering. summary: Retrieve metadata values from findings @@ -4758,6 +5765,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -4777,7 +5799,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -4791,10 +5813,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -4818,7 +5840,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -4832,10 +5854,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -5112,7 +6134,7 @@ paths: description: '' /api/v1/findings/metadata/latest: get: - operationId: findings_metadata_latest_retrieve + operationId: api_v1_findings_metadata_latest_retrieve description: Fetch unique metadata values from a set of findings from the latest scans for each provider. This is useful for dynamic filtering. summary: Retrieve metadata values from the latest findings @@ -5262,6 +6284,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -5281,7 +6318,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -5295,10 +6332,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -5322,7 +6359,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -5336,10 +6373,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -5591,7 +6628,7 @@ paths: description: '' /api/v1/integrations: get: - operationId: integrations_list + operationId: api_v1_integrations_list description: Retrieve a list of all configured integrations with options for filtering by various criteria. summary: List all integrations @@ -5742,7 +6779,7 @@ paths: $ref: '#/components/schemas/PaginatedIntegrationList' description: '' post: - operationId: integrations_create + operationId: api_v1_integrations_create description: Register a new integration with the system, providing necessary configuration details. summary: Create a new integration @@ -5771,7 +6808,7 @@ paths: description: '' /api/v1/integrations/{integration_pk}/jira/dispatches: post: - operationId: integrations_jira_dispatches_create + operationId: api_v1_integrations_jira_dispatches_create description: |- Send a set of filtered findings to the given integration. At least one finding filter must be provided. @@ -5844,7 +6881,7 @@ paths: description: '' /api/v1/integrations/{integration_pk}/jira/issue_types: get: - operationId: integrations_jira_issue_types_retrieve + operationId: api_v1_integrations_jira_issue_types_retrieve description: Fetch the available issue types from Jira for a given project key and update the integration configuration. summary: Get available issue types for a Jira project @@ -5885,7 +6922,7 @@ paths: description: '' /api/v1/integrations/{id}: get: - operationId: integrations_retrieve + operationId: api_v1_integrations_retrieve description: Fetch detailed information about a specific integration by its ID. summary: Retrieve integration details @@ -5939,7 +6976,7 @@ paths: $ref: '#/components/schemas/IntegrationResponse' description: '' patch: - operationId: integrations_partial_update + operationId: api_v1_integrations_partial_update description: Modify certain fields of an existing integration without affecting other settings. summary: Partially update an integration @@ -5975,7 +7012,7 @@ paths: $ref: '#/components/schemas/IntegrationUpdateResponse' description: '' delete: - operationId: integrations_destroy + operationId: api_v1_integrations_destroy description: Remove an integration from the system by its ID. summary: Delete an integration parameters: @@ -5995,7 +7032,7 @@ paths: description: No response body /api/v1/integrations/{id}/connection: post: - operationId: integrations_connection_create + operationId: api_v1_integrations_connection_create description: Try to verify integration connection summary: Check integration connection parameters: @@ -6034,7 +7071,7 @@ paths: description: '' /api/v1/invitations/accept: post: - operationId: invitations_accept_create + operationId: api_v1_invitations_accept_create description: Accept an invitation to an existing tenant. This invitation cannot be expired and the emails must match. summary: Accept an invitation @@ -6063,7 +7100,7 @@ paths: description: '' /api/v1/lighthouse-configurations: get: - operationId: lighthouse_configurations_list + operationId: api_v1_lighthouse_configurations_list description: Retrieve a list of all Lighthouse AI configurations. summary: List all Lighthouse AI configurations parameters: @@ -6136,7 +7173,7 @@ paths: $ref: '#/components/schemas/PaginatedLighthouseConfigList' description: '' post: - operationId: lighthouse_configurations_create + operationId: api_v1_lighthouse_configurations_create description: Create a new Lighthouse AI configuration with the specified details. summary: Create a new Lighthouse AI configuration tags: @@ -6165,7 +7202,7 @@ paths: description: '' /api/v1/lighthouse-configurations/{id}: patch: - operationId: lighthouse_configurations_partial_update + operationId: api_v1_lighthouse_configurations_partial_update description: Update certain fields of an existing Lighthouse AI configuration. summary: Partially update a Lighthouse AI configuration parameters: @@ -6199,7 +7236,7 @@ paths: $ref: '#/components/schemas/LighthouseConfigUpdateResponse' description: '' delete: - operationId: lighthouse_configurations_destroy + operationId: api_v1_lighthouse_configurations_destroy description: Remove a Lighthouse AI configuration by its ID. summary: Delete a Lighthouse AI configuration parameters: @@ -6218,7 +7255,7 @@ paths: description: No response body /api/v1/lighthouse-configurations/{id}/connection: post: - operationId: lighthouse_configurations_connection_create + operationId: api_v1_lighthouse_configurations_connection_create description: Verify the connection to the OpenAI API for a specific Lighthouse AI configuration. summary: Check the connection to the OpenAI API @@ -6257,7 +7294,7 @@ paths: description: '' /api/v1/lighthouse/configuration: get: - operationId: lighthouse_configuration_list + operationId: api_v1_lighthouse_configuration_list description: Retrieve current tenant-level Lighthouse AI settings. Returns a single configuration object. summary: Get Lighthouse AI Tenant config @@ -6332,7 +7369,7 @@ paths: $ref: '#/components/schemas/PaginatedLighthouseTenantConfigList' description: '' patch: - operationId: lighthouse_configuration_partial_update + operationId: api_v1_lighthouse_configuration_partial_update description: Update tenant-level settings. Validates that the default provider is configured and active and that default model IDs exist for the chosen providers. Auto-creates configuration if it doesn't exist. @@ -6362,7 +7399,7 @@ paths: description: '' /api/v1/lighthouse/models: get: - operationId: lighthouse_models_list + operationId: api_v1_lighthouse_models_list description: List available LLM models per configured provider for the current tenant. summary: List all LLM models @@ -6492,7 +7529,7 @@ paths: description: '' /api/v1/lighthouse/models/{id}: get: - operationId: lighthouse_models_retrieve + operationId: api_v1_lighthouse_models_retrieve description: Get details for a specific LLM model. summary: Retrieve LLM model details parameters: @@ -6533,7 +7570,7 @@ paths: description: '' /api/v1/lighthouse/providers: get: - operationId: lighthouse_providers_list + operationId: api_v1_lighthouse_providers_list description: Retrieve all LLM provider configurations for the current tenant summary: List all LLM provider configurations parameters: @@ -6648,7 +7685,7 @@ paths: $ref: '#/components/schemas/PaginatedLighthouseProviderConfigList' description: '' post: - operationId: lighthouse_providers_create + operationId: api_v1_lighthouse_providers_create description: Create a per-tenant configuration for an LLM provider. Only one configuration per provider type is allowed per tenant. summary: Create LLM provider configuration @@ -6677,7 +7714,7 @@ paths: description: '' /api/v1/lighthouse/providers/{id}: get: - operationId: lighthouse_providers_retrieve + operationId: api_v1_lighthouse_providers_retrieve description: Get details for a specific provider configuration in the current tenant. summary: Retrieve LLM provider configuration @@ -6718,7 +7755,7 @@ paths: $ref: '#/components/schemas/LighthouseProviderConfigResponse' description: '' patch: - operationId: lighthouse_providers_partial_update + operationId: api_v1_lighthouse_providers_partial_update description: Partially update a provider configuration (e.g., base_url, is_active). summary: Update LLM provider configuration parameters: @@ -6753,7 +7790,7 @@ paths: $ref: '#/components/schemas/LighthouseProviderConfigUpdateResponse' description: '' delete: - operationId: lighthouse_providers_destroy + operationId: api_v1_lighthouse_providers_destroy description: Delete a provider configuration. Any tenant defaults that reference this provider are cleared during deletion. summary: Delete LLM provider configuration @@ -6774,7 +7811,7 @@ paths: description: No response body /api/v1/lighthouse/providers/{id}/connection: post: - operationId: lighthouse_providers_connection_create + operationId: api_v1_lighthouse_providers_connection_create description: Validate provider credentials asynchronously and toggle is_active. summary: Check LLM provider connection parameters: @@ -6813,7 +7850,7 @@ paths: description: '' /api/v1/lighthouse/providers/{id}/refresh-models: post: - operationId: lighthouse_providers_refresh_models_create + operationId: api_v1_lighthouse_providers_refresh_models_create description: Fetch available models for this provider configuration and upsert into catalog. Supports OpenAI, OpenAI-compatible, and AWS Bedrock providers. summary: Refresh LLM models catalog @@ -6853,7 +7890,7 @@ paths: description: '' /api/v1/mute-rules: get: - operationId: mute_rules_list + operationId: api_v1_mute_rules_list description: Retrieve a list of all mute rules with filtering options. summary: List all mute rules parameters: @@ -6999,7 +8036,7 @@ paths: $ref: '#/components/schemas/PaginatedMuteRuleList' description: '' post: - operationId: mute_rules_create + operationId: api_v1_mute_rules_create description: Create a new mute rule by providing finding IDs, name, and reason. The rule will immediately mute the selected findings and launch a background task to mute all historical findings with matching UIDs. @@ -7029,7 +8066,7 @@ paths: description: '' /api/v1/mute-rules/{id}: get: - operationId: mute_rules_retrieve + operationId: api_v1_mute_rules_retrieve description: Fetch detailed information about a specific mute rule by ID. summary: Retrieve a mute rule parameters: @@ -7080,7 +8117,7 @@ paths: $ref: '#/components/schemas/MuteRuleResponse' description: '' patch: - operationId: mute_rules_partial_update + operationId: api_v1_mute_rules_partial_update description: Update certain fields of an existing mute rule (e.g., name, reason, enabled). summary: Partially update a mute rule @@ -7116,7 +8153,7 @@ paths: $ref: '#/components/schemas/SerializerMetaclassResponse' description: '' delete: - operationId: mute_rules_destroy + operationId: api_v1_mute_rules_destroy description: 'Remove a mute rule from the system. Note: Previously muted findings remain muted.' summary: Delete a mute rule @@ -7137,7 +8174,7 @@ paths: description: No response body /api/v1/overviews/attack-surfaces: get: - operationId: overviews_attack_surfaces_list + operationId: api_v1_overviews_attack_surfaces_list description: Retrieve aggregated attack surface metrics from latest completed scans per provider. summary: Get attack surface overview @@ -7156,6 +8193,21 @@ paths: description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -7175,7 +8227,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -7189,10 +8241,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7216,7 +8268,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -7230,10 +8282,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -7304,7 +8356,7 @@ paths: description: '' /api/v1/overviews/categories: get: - operationId: overviews_categories_list + operationId: api_v1_overviews_categories_list description: 'Retrieve aggregated category metrics from latest completed scans per provider. Returns one row per category with total, failed, and new failed findings counts, plus a severity breakdown showing failed findings per severity @@ -7339,6 +8391,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -7358,7 +8425,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -7372,10 +8439,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7399,7 +8466,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -7413,10 +8480,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -7489,7 +8556,7 @@ paths: description: '' /api/v1/overviews/compliance-watchlist: get: - operationId: overviews_compliance_watchlist_list + operationId: api_v1_overviews_compliance_watchlist_list description: 'Retrieve compliance metrics with FAIL-dominant aggregation. Without filters: uses pre-aggregated TenantComplianceSummary. With provider filters: queries ProviderComplianceScore with FAIL-dominant logic where any FAIL in @@ -7512,6 +8579,21 @@ paths: description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -7544,10 +8626,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7584,10 +8666,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -7662,7 +8744,7 @@ paths: description: '' /api/v1/overviews/findings: get: - operationId: overviews_findings_retrieve + operationId: api_v1_overviews_findings_retrieve description: Fetch aggregated findings data across all providers, grouped by various metrics such as passed, failed, muted, and total findings. This endpoint calculates summary statistics based on the latest scans for each provider @@ -7714,6 +8796,21 @@ paths: schema: type: string format: date-time + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -7733,7 +8830,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -7747,10 +8844,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7774,7 +8871,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -7788,10 +8885,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -7887,7 +8984,7 @@ paths: description: '' /api/v1/overviews/findings_severity: get: - operationId: overviews_findings_severity_retrieve + operationId: api_v1_overviews_findings_severity_retrieve description: Retrieve an aggregated summary of findings grouped by severity levels, such as low, medium, high, and critical. The response includes the total count of findings for each severity, considering only the latest scans @@ -7931,6 +9028,21 @@ paths: schema: type: string format: date-time + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -7950,7 +9062,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -7964,10 +9076,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7991,7 +9103,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -8005,10 +9117,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -8107,7 +9219,7 @@ paths: description: '' /api/v1/overviews/findings_severity/timeseries: get: - operationId: overviews_findings_severity_timeseries_retrieve + operationId: api_v1_overviews_findings_severity_timeseries_retrieve description: Retrieve daily aggregated findings data grouped by severity levels over a date range. Returns one data point per day with counts of failed findings by severity (critical, high, medium, low, informational) and muted findings. @@ -8143,6 +9255,21 @@ paths: schema: type: string format: date + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -8175,10 +9302,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8215,10 +9342,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -8285,7 +9412,7 @@ paths: description: '' /api/v1/overviews/providers: get: - operationId: overviews_providers_retrieve + operationId: api_v1_overviews_providers_retrieve description: Retrieve an aggregated overview of findings and resources grouped by providers. The response includes the count of passed, failed, and manual findings, along with the total number of resources managed by each provider. @@ -8319,7 +9446,7 @@ paths: description: '' /api/v1/overviews/providers/count: get: - operationId: overviews_providers_count_retrieve + operationId: api_v1_overviews_providers_count_retrieve description: Retrieve the number of providers grouped by provider type. This endpoint counts every provider in the tenant, including those without completed scans. @@ -8350,7 +9477,7 @@ paths: description: '' /api/v1/overviews/regions: get: - operationId: overviews_regions_retrieve + operationId: api_v1_overviews_regions_retrieve description: Retrieve an aggregated summary of findings grouped by region. The response includes the total, passed, failed, and muted findings for each region based on the latest completed scans per provider. Standard overview filters @@ -8394,6 +9521,21 @@ paths: schema: type: string format: date-time + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -8413,7 +9555,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -8427,10 +9569,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8454,7 +9596,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -8468,10 +9610,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -8553,7 +9695,7 @@ paths: description: '' /api/v1/overviews/resource-groups: get: - operationId: overviews_resource_groups_list + operationId: api_v1_overviews_resource_groups_list description: Retrieve aggregated resource group metrics from latest completed scans per provider. Returns one row per resource group with total, failed, and new failed findings counts, plus a severity breakdown showing failed findings @@ -8576,6 +9718,21 @@ paths: description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -8595,7 +9752,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -8609,10 +9766,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8636,7 +9793,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -8650,10 +9807,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -8741,7 +9898,7 @@ paths: description: '' /api/v1/overviews/services: get: - operationId: overviews_services_retrieve + operationId: api_v1_overviews_services_retrieve description: Retrieve an aggregated summary of findings grouped by service. The response includes the total count of findings for each service, as long as there are at least one finding for that service. @@ -8782,6 +9939,21 @@ paths: schema: type: string format: date-time + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -8801,7 +9973,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -8815,10 +9987,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8842,7 +10014,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -8856,10 +10028,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -8937,7 +10109,7 @@ paths: description: '' /api/v1/overviews/threatscore: get: - operationId: overviews_threatscore_retrieve + operationId: api_v1_overviews_threatscore_retrieve description: Retrieve ThreatScore metrics. By default, returns the latest snapshot for each provider. Use snapshot_id to retrieve a specific historical snapshot. summary: Get ThreatScore snapshots @@ -8968,6 +10140,38 @@ paths: description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + description: Filter by provider group ID + - in: query + name: filter[provider_groups__in] + schema: + type: string + description: Filter by multiple provider group IDs (comma-separated UUIDs) + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + description: Filter by specific provider ID + - in: query + name: filter[provider_id__in] + schema: + type: string + description: Filter by multiple provider IDs (comma-separated UUIDs) + - in: query + name: filter[provider_type] + schema: + type: string + description: Filter by provider type (aws, azure, gcp, etc.) + - in: query + name: filter[provider_type__in] + schema: + type: string + description: Filter by multiple provider types (comma-separated) - in: query name: include schema: @@ -8980,27 +10184,6 @@ paths: description: include query parameter to allow the client to customize which related resources should be returned. explode: false - - in: query - name: provider_id - schema: - type: string - format: uuid - description: Filter by specific provider ID - - in: query - name: provider_id__in - schema: - type: string - description: Filter by multiple provider IDs (comma-separated UUIDs) - - in: query - name: provider_type - schema: - type: string - description: Filter by provider type (aws, azure, gcp, etc.) - - in: query - name: provider_type__in - schema: - type: string - description: Filter by multiple provider types (comma-separated) - in: query name: snapshot_id schema: @@ -9021,7 +10204,7 @@ paths: description: '' /api/v1/processors: get: - operationId: processors_list + operationId: api_v1_processors_list description: Retrieve a list of all configured processors with options for filtering by various criteria. summary: List all processors @@ -9116,7 +10299,7 @@ paths: $ref: '#/components/schemas/PaginatedProcessorList' description: '' post: - operationId: processors_create + operationId: api_v1_processors_create description: Register a new processor with the system, providing necessary configuration details. There can only be one processor of each type per tenant. summary: Create a new processor @@ -9145,7 +10328,7 @@ paths: description: '' /api/v1/processors/{id}: get: - operationId: processors_retrieve + operationId: api_v1_processors_retrieve description: Fetch detailed information about a specific processor by its ID. summary: Retrieve processor details parameters: @@ -9183,7 +10366,7 @@ paths: $ref: '#/components/schemas/ProcessorResponse' description: '' patch: - operationId: processors_partial_update + operationId: api_v1_processors_partial_update description: Modify certain fields of an existing processor without affecting other settings. summary: Partially update a processor @@ -9219,7 +10402,7 @@ paths: $ref: '#/components/schemas/ProcessorUpdateResponse' description: '' delete: - operationId: processors_destroy + operationId: api_v1_processors_destroy description: Remove a processor from the system by its ID. summary: Delete a processor parameters: @@ -9239,7 +10422,7 @@ paths: description: No response body /api/v1/provider-groups: get: - operationId: provider_groups_list + operationId: api_v1_provider_groups_list description: Retrieve a list of all provider groups with options for filtering by various criteria. summary: List all provider groups @@ -9372,7 +10555,7 @@ paths: $ref: '#/components/schemas/PaginatedProviderGroupList' description: '' post: - operationId: provider_groups_create + operationId: api_v1_provider_groups_create description: Add a new provider group to the system by providing the required provider group details. summary: Create a new provider group @@ -9401,7 +10584,7 @@ paths: description: '' /api/v1/provider-groups/{id}: get: - operationId: provider_groups_retrieve + operationId: api_v1_provider_groups_retrieve description: Fetch detailed information about a specific provider group by their ID. summary: Retrieve data from a provider group @@ -9441,7 +10624,7 @@ paths: $ref: '#/components/schemas/ProviderGroupResponse' description: '' patch: - operationId: provider_groups_partial_update + operationId: api_v1_provider_groups_partial_update description: Update certain fields of an existing provider group's information without affecting other fields. summary: Partially update a provider group @@ -9477,7 +10660,7 @@ paths: $ref: '#/components/schemas/SerializerMetaclassResponse' description: '' delete: - operationId: provider_groups_destroy + operationId: api_v1_provider_groups_destroy description: Remove a provider group from the system by their ID. summary: Delete a provider group parameters: @@ -9497,7 +10680,7 @@ paths: description: No response body /api/v1/provider-groups/{id}/relationships/providers: post: - operationId: provider_groups_relationships_providers_create + operationId: api_v1_provider_groups_relationships_providers_create description: Add a new provider_group-providers relationship to the system by providing the required provider_group-providers details. summary: Create a new provider_group-providers relationship @@ -9523,7 +10706,7 @@ paths: '400': description: Bad request (e.g., relationship already exists) patch: - operationId: provider_groups_relationships_providers_partial_update + operationId: api_v1_provider_groups_relationships_providers_partial_update description: Update the provider_group-providers relationship information without affecting other fields. summary: Partially update a provider_group-providers relationship @@ -9547,7 +10730,7 @@ paths: '204': description: Relationship updated successfully delete: - operationId: provider_groups_relationships_providers_destroy + operationId: api_v1_provider_groups_relationships_providers_destroy description: Remove the provider_group-providers relationship from the system by their ID. summary: Delete a provider_group-providers relationship @@ -9560,7 +10743,7 @@ paths: description: Relationship deleted successfully /api/v1/providers: get: - operationId: providers_list + operationId: api_v1_providers_list description: Retrieve a list of all providers with options for filtering by various criteria. summary: List all providers @@ -9648,7 +10831,7 @@ paths: name: filter[provider] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -9662,10 +10845,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -9689,7 +10872,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -9703,10 +10886,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -9728,11 +10911,26 @@ paths: * `okta` - Okta explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -9746,10 +10944,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -9773,7 +10971,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -9787,10 +10985,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -9910,7 +11108,7 @@ paths: $ref: '#/components/schemas/PaginatedProviderList' description: '' post: - operationId: providers_create + operationId: api_v1_providers_create description: Add a new provider to the system by providing the required provider details. summary: Create a new provider @@ -9939,7 +11137,7 @@ paths: description: '' /api/v1/providers/{id}: get: - operationId: providers_retrieve + operationId: api_v1_providers_retrieve description: Fetch detailed information about a specific provider by their ID. summary: Retrieve data from a provider parameters: @@ -9992,7 +11190,7 @@ paths: $ref: '#/components/schemas/ProviderResponse' description: '' patch: - operationId: providers_partial_update + operationId: api_v1_providers_partial_update description: Update certain fields of an existing provider's information without affecting other fields. summary: Partially update a provider @@ -10028,7 +11226,7 @@ paths: $ref: '#/components/schemas/SerializerMetaclassResponse' description: '' delete: - operationId: providers_destroy + operationId: api_v1_providers_destroy description: Remove a provider from the system by their ID. summary: Delete a provider parameters: @@ -10067,7 +11265,7 @@ paths: description: '' /api/v1/providers/{id}/connection: post: - operationId: providers_connection_create + operationId: api_v1_providers_connection_create description: Try to verify connection. For instance, Role & Credentials are set correctly summary: Check connection @@ -10107,7 +11305,7 @@ paths: description: '' /api/v1/providers/secrets: get: - operationId: providers_secrets_list + operationId: api_v1_providers_secrets_list description: Retrieve a list of all secrets with options for filtering by various criteria. summary: List all secrets @@ -10199,7 +11397,7 @@ paths: $ref: '#/components/schemas/PaginatedProviderSecretList' description: '' post: - operationId: providers_secrets_create + operationId: api_v1_providers_secrets_create description: Add a new secret to the system by providing the required secret details. summary: Create a new secret @@ -10228,7 +11426,7 @@ paths: description: '' /api/v1/providers/secrets/{id}: get: - operationId: providers_secrets_retrieve + operationId: api_v1_providers_secrets_retrieve description: Fetch detailed information about a specific secret by their ID. summary: Retrieve data from a secret parameters: @@ -10266,7 +11464,7 @@ paths: $ref: '#/components/schemas/ProviderSecretResponse' description: '' patch: - operationId: providers_secrets_partial_update + operationId: api_v1_providers_secrets_partial_update description: Update certain fields of an existing secret's information without affecting other fields. summary: Partially update a secret @@ -10301,7 +11499,7 @@ paths: $ref: '#/components/schemas/ProviderSecretUpdateResponse' description: '' delete: - operationId: providers_secrets_destroy + operationId: api_v1_providers_secrets_destroy description: Remove a secret from the system by their ID. summary: Delete a secret parameters: @@ -10320,7 +11518,7 @@ paths: description: No response body /api/v1/resources: get: - operationId: resources_list + operationId: api_v1_resources_list description: Retrieve a list of all resources with options for filtering by various criteria. Resources are objects that are discovered by Prowler. They can be anything from a single host to a whole VPC. @@ -10444,6 +11642,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -10463,7 +11676,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -10477,10 +11690,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -10504,7 +11717,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -10518,10 +11731,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -10746,7 +11959,7 @@ paths: description: '' /api/v1/resources/{id}: get: - operationId: resources_retrieve + operationId: api_v1_resources_retrieve description: Fetch detailed information about a specific resource by their ID. A Resource is an object that is discovered by Prowler. It can be anything from a single host to a whole VPC. @@ -10810,7 +12023,7 @@ paths: description: '' /api/v1/resources/{id}/events: get: - operationId: resources_events_list + operationId: api_v1_resources_events_list description: |- Retrieve events showing modification history for a resource. Returns who modified the resource and when. Currently only available for AWS resources. @@ -10891,7 +12104,7 @@ paths: description: Provider service unavailable /api/v1/resources/latest: get: - operationId: resources_latest_retrieve + operationId: api_v1_resources_latest_retrieve description: Retrieve a list of the latest resources from the latest scans for each provider with options for filtering by various criteria. summary: List the latest resources @@ -10999,6 +12212,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -11018,7 +12246,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -11032,10 +12260,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -11059,7 +12287,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -11073,10 +12301,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -11256,7 +12484,7 @@ paths: description: '' /api/v1/resources/metadata: get: - operationId: resources_metadata_retrieve + operationId: api_v1_resources_metadata_retrieve description: Fetch unique metadata values from a set of resources. This is useful for dynamic filtering. summary: Retrieve metadata values from resources @@ -11367,6 +12595,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -11386,7 +12629,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -11400,10 +12643,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -11427,7 +12670,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -11441,10 +12684,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -11645,7 +12888,7 @@ paths: description: '' /api/v1/resources/metadata/latest: get: - operationId: resources_metadata_latest_retrieve + operationId: api_v1_resources_metadata_latest_retrieve description: Fetch unique metadata values from a set of resources from the latest scans for each provider. This is useful for dynamic filtering. summary: Retrieve metadata values from the latest resources @@ -11741,6 +12984,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -11760,7 +13018,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -11774,10 +13032,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -11801,7 +13059,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -11815,10 +13073,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -11986,7 +13244,7 @@ paths: description: '' /api/v1/roles: get: - operationId: roles_list + operationId: api_v1_roles_list description: Retrieve a list of all roles with options for filtering by various criteria. summary: List all roles @@ -12155,7 +13413,7 @@ paths: $ref: '#/components/schemas/PaginatedRoleList' description: '' post: - operationId: roles_create + operationId: api_v1_roles_create description: Add a new role to the system by providing the required role details. summary: Create a new role tags: @@ -12183,7 +13441,7 @@ paths: description: '' /api/v1/roles/{id}: get: - operationId: roles_retrieve + operationId: api_v1_roles_retrieve description: Fetch detailed information about a specific role by their ID. summary: Retrieve data from a role parameters: @@ -12230,7 +13488,7 @@ paths: $ref: '#/components/schemas/RoleResponse' description: '' patch: - operationId: roles_partial_update + operationId: api_v1_roles_partial_update description: Update selected fields on an existing role. When changing the `users` relationship of a role that grants MANAGE_ACCOUNT, the API blocks attempts that would leave the tenant without any MANAGE_ACCOUNT assignees and prevents @@ -12268,7 +13526,7 @@ paths: $ref: '#/components/schemas/SerializerMetaclassResponse' description: '' delete: - operationId: roles_destroy + operationId: api_v1_roles_destroy description: Delete the specified role. The API rejects deletion of the last role in the tenant that grants MANAGE_ACCOUNT. summary: Delete a role @@ -12289,7 +13547,7 @@ paths: description: No response body /api/v1/roles/{id}/relationships/provider_groups: post: - operationId: roles_relationships_provider_groups_create + operationId: api_v1_roles_relationships_provider_groups_create description: Add a new role-provider_groups relationship to the system by providing the required role-provider_groups details. summary: Create a new role-provider_groups relationship @@ -12315,7 +13573,7 @@ paths: '400': description: Bad request (e.g., relationship already exists) patch: - operationId: roles_relationships_provider_groups_partial_update + operationId: api_v1_roles_relationships_provider_groups_partial_update description: Update the role-provider_groups relationship information without affecting other fields. summary: Partially update a role-provider_groups relationship @@ -12339,7 +13597,7 @@ paths: '204': description: Relationship updated successfully delete: - operationId: roles_relationships_provider_groups_destroy + operationId: api_v1_roles_relationships_provider_groups_destroy description: Remove the role-provider_groups relationship from the system by their ID. summary: Delete a role-provider_groups relationship @@ -12352,7 +13610,7 @@ paths: description: Relationship deleted successfully /api/v1/saml-config: get: - operationId: saml_config_list + operationId: api_v1_saml_config_list description: Returns all the SAML-based SSO configurations associated with the current tenant. summary: List all SSO configurations @@ -12421,7 +13679,7 @@ paths: $ref: '#/components/schemas/PaginatedSAMLConfigurationList' description: '' post: - operationId: saml_config_create + operationId: api_v1_saml_config_create description: Creates a new SAML SSO configuration for the current tenant, including email domain and metadata XML. summary: Create the SSO configuration @@ -12450,7 +13708,7 @@ paths: description: '' /api/v1/saml-config/{id}: get: - operationId: saml_config_retrieve + operationId: api_v1_saml_config_retrieve description: Returns the details of a specific SAML configuration belonging to the current tenant. summary: Retrieve SSO configuration details @@ -12488,7 +13746,7 @@ paths: $ref: '#/components/schemas/SAMLConfigurationResponse' description: '' patch: - operationId: saml_config_partial_update + operationId: api_v1_saml_config_partial_update description: Partially updates an existing SAML SSO configuration. Supports changes to email domain and metadata XML. summary: Update the SSO configuration @@ -12524,7 +13782,7 @@ paths: $ref: '#/components/schemas/SAMLConfigurationResponse' description: '' delete: - operationId: saml_config_destroy + operationId: api_v1_saml_config_destroy description: Deletes an existing SAML SSO configuration associated with the current tenant. summary: Delete the SSO configuration @@ -12545,7 +13803,7 @@ paths: description: No response body /api/v1/scans: get: - operationId: scans_list + operationId: api_v1_scans_list description: Retrieve a list of all scans with options for filtering by various criteria. summary: List all scans @@ -12655,11 +13913,26 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -12673,10 +13946,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -12700,7 +13973,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -12714,10 +13987,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -12889,7 +14162,7 @@ paths: $ref: '#/components/schemas/PaginatedScanList' description: '' post: - operationId: scans_create + operationId: api_v1_scans_create description: Trigger a manual scan by providing the required scan details. If `scanner_args` are not provided, the system will automatically use the default settings from the associated provider. If you do provide `scanner_args`, these @@ -12937,7 +14210,7 @@ paths: description: '' /api/v1/scans/{id}: get: - operationId: scans_retrieve + operationId: api_v1_scans_retrieve description: Fetch detailed information about a specific scan by its ID. summary: Retrieve data from a specific scan parameters: @@ -12996,7 +14269,7 @@ paths: $ref: '#/components/schemas/ScanResponse' description: '' patch: - operationId: scans_partial_update + operationId: api_v1_scans_partial_update description: Update certain fields of an existing scan without affecting other fields. summary: Partially update a scan @@ -13033,7 +14306,7 @@ paths: description: '' /api/v1/scans/{id}/cis: get: - operationId: scans_cis_retrieve + operationId: api_v1_scans_cis_retrieve description: Download the CIS Benchmark compliance report as a PDF file. When a provider ships multiple CIS versions, the report is generated for the highest available version. @@ -13100,7 +14373,7 @@ paths: has not started yet /api/v1/scans/{id}/compliance/{name}: get: - operationId: scans_compliance_retrieve + operationId: api_v1_scans_compliance_retrieve description: Download a specific compliance report (e.g., 'cis_1.4_aws') as a CSV file. summary: Retrieve compliance report as CSV @@ -13145,10 +14418,11 @@ paths: 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 + operationId: api_v1_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. + (currently 'dora_2022_2554', 'csa_ccm_4.0' and 'cis_controls_8.1'); any other + framework returns 404. summary: Retrieve compliance report as OCSF JSON parameters: - in: query @@ -13174,7 +14448,7 @@ paths: name: name schema: type: string - description: The compliance report name, like 'dora' + description: The compliance report name, like 'dora_2022_2554' required: true tags: - Scan @@ -13192,7 +14466,7 @@ paths: an OCSF export, or the scan has no reports yet /api/v1/scans/{id}/csa: get: - operationId: scans_csa_retrieve + operationId: api_v1_scans_csa_retrieve description: Download CSA Cloud Controls Matrix (CCM) v4.0 compliance report as a PDF file. summary: Retrieve CSA CCM compliance report @@ -13258,7 +14532,7 @@ paths: task has not started yet /api/v1/scans/{id}/ens: get: - operationId: scans_ens_retrieve + operationId: api_v1_scans_ens_retrieve description: Download ENS RD2022 compliance report (e.g., 'ens_rd2022_aws') as a PDF file. summary: Retrieve ENS RD2022 compliance report @@ -13324,7 +14598,7 @@ paths: has not started yet /api/v1/scans/{id}/nis2: get: - operationId: scans_nis2_retrieve + operationId: api_v1_scans_nis2_retrieve description: Download NIS2 compliance report (Directive (EU) 2022/2555) as a PDF file. summary: Retrieve NIS2 compliance report @@ -13390,7 +14664,7 @@ paths: task has not started yet /api/v1/scans/{id}/report: get: - operationId: scans_report_retrieve + operationId: api_v1_scans_report_retrieve description: Returns a ZIP file containing the requested report summary: Download ZIP report parameters: @@ -13428,7 +14702,7 @@ paths: not started yet /api/v1/scans/{id}/threatscore: get: - operationId: scans_threatscore_retrieve + operationId: api_v1_scans_threatscore_retrieve description: Download a specific threatscore report (e.g., 'prowler_threatscore_aws') as a PDF file. summary: Retrieve threatscore report @@ -13494,7 +14768,7 @@ paths: generation task has not started yet /api/v1/schedules/daily: post: - operationId: schedules_daily_create + operationId: api_v1_schedules_daily_create description: Schedules a daily scan for the specified provider. This endpoint creates a periodic task that will execute a scan every 24 hours. summary: Create a daily schedule scan for a given provider @@ -13538,7 +14812,7 @@ paths: description: '' /api/v1/tasks: get: - operationId: tasks_list + operationId: api_v1_tasks_list description: Retrieve a list of all tasks with options for filtering by name, state, and other criteria. summary: List all tasks @@ -13638,7 +14912,7 @@ paths: description: '' /api/v1/tasks/{id}: get: - operationId: tasks_retrieve + operationId: api_v1_tasks_retrieve description: Fetch detailed information about a specific task by its ID. summary: Retrieve data from a specific task parameters: @@ -13678,7 +14952,7 @@ paths: $ref: '#/components/schemas/TaskResponse' description: '' delete: - operationId: tasks_destroy + operationId: api_v1_tasks_destroy description: Try to revoke a task using its ID. Only tasks that are not yet in progress can be revoked. summary: Revoke a task @@ -13718,7 +14992,7 @@ paths: description: '' /api/v1/tenants: get: - operationId: tenants_list + operationId: api_v1_tenants_list description: Retrieve a list of all tenants with options for filtering by various criteria. summary: List all tenants @@ -13824,7 +15098,7 @@ paths: $ref: '#/components/schemas/PaginatedTenantList' description: '' post: - operationId: tenants_create + operationId: api_v1_tenants_create description: Add a new tenant to the system by providing the required tenant details. summary: Create a new tenant @@ -13853,7 +15127,7 @@ paths: description: '' /api/v1/tenants/{id}: get: - operationId: tenants_retrieve + operationId: api_v1_tenants_retrieve description: Fetch detailed information about a specific tenant by their ID. summary: Retrieve data from a tenant parameters: @@ -13888,7 +15162,7 @@ paths: $ref: '#/components/schemas/TenantResponse' description: '' patch: - operationId: tenants_partial_update + operationId: api_v1_tenants_partial_update description: Update certain fields of an existing tenant's information without affecting other fields. summary: Partially update a tenant @@ -13924,7 +15198,7 @@ paths: $ref: '#/components/schemas/TenantResponse' description: '' delete: - operationId: tenants_destroy + operationId: api_v1_tenants_destroy description: Remove a tenant from the system by their ID. summary: Delete a tenant parameters: @@ -13944,7 +15218,7 @@ paths: description: No response body /api/v1/tenants/{tenant_pk}/memberships: get: - operationId: tenants_memberships_list + operationId: api_v1_tenants_memberships_list description: List the membership details of users in a tenant you are a part of. summary: List tenant memberships @@ -14062,7 +15336,7 @@ paths: description: '' /api/v1/tenants/{tenant_pk}/memberships/{id}: delete: - operationId: tenants_memberships_destroy + operationId: api_v1_tenants_memberships_destroy description: 'Delete a user''s membership from a tenant. This action: (1) removes the membership, (2) revokes all refresh tokens for the expelled user, (3) removes their role grants for this tenant, (4) cleans up orphaned roles, and @@ -14093,7 +15367,7 @@ paths: description: No response body /api/v1/tenants/invitations: get: - operationId: tenants_invitations_list + operationId: api_v1_tenants_invitations_list description: Retrieve a list of all tenant invitations with options for filtering by various criteria. summary: List all invitations @@ -14275,7 +15549,7 @@ paths: $ref: '#/components/schemas/PaginatedInvitationList' description: '' post: - operationId: tenants_invitations_create + operationId: api_v1_tenants_invitations_create description: Add a new tenant invitation to the system by providing the required invitation details. The invited user will have to accept the invitations or create an account using the given code. @@ -14305,7 +15579,7 @@ paths: description: '' /api/v1/tenants/invitations/{id}: get: - operationId: tenants_invitations_retrieve + operationId: api_v1_tenants_invitations_retrieve description: Fetch detailed information about a specific invitation by its ID. summary: Retrieve data from a tenant invitation parameters: @@ -14346,7 +15620,7 @@ paths: $ref: '#/components/schemas/InvitationResponse' description: '' patch: - operationId: tenants_invitations_partial_update + operationId: api_v1_tenants_invitations_partial_update description: Update certain fields of an existing tenant invitation's information without affecting other fields. summary: Partially update a tenant invitation @@ -14381,7 +15655,7 @@ paths: $ref: '#/components/schemas/InvitationUpdateResponse' description: '' delete: - operationId: tenants_invitations_destroy + operationId: api_v1_tenants_invitations_destroy description: Revoke a tenant invitation from the system by their ID. summary: Revoke a tenant invitation parameters: @@ -14400,7 +15674,7 @@ paths: description: No response body /api/v1/tokens: post: - operationId: tokens_create + operationId: api_v1_tokens_create description: Obtain a token by providing valid credentials and an optional tenant ID. summary: Obtain a token @@ -14430,7 +15704,7 @@ paths: description: '' /api/v1/tokens/refresh: post: - operationId: tokens_refresh_create + operationId: api_v1_tokens_refresh_create description: Refresh an access token by providing a valid refresh token. Former refresh tokens are invalidated when a new one is issued. summary: Refresh a token @@ -14460,7 +15734,7 @@ paths: description: '' /api/v1/tokens/switch: post: - operationId: tokens_switch_create + operationId: api_v1_tokens_switch_create description: Switch tenant by providing a valid tenant ID. The authenticated user must belong to the tenant. summary: Switch tenant using a valid tenant ID @@ -14489,7 +15763,7 @@ paths: description: '' /api/v1/users: get: - operationId: users_list + operationId: api_v1_users_list description: Retrieve a list of all users with options for filtering by various criteria. summary: List all users @@ -14620,7 +15894,7 @@ paths: $ref: '#/components/schemas/PaginatedUserList' description: '' post: - operationId: users_create + operationId: api_v1_users_create description: Create a new user account by providing the necessary registration details. summary: Register a new user @@ -14657,7 +15931,7 @@ paths: description: '' /api/v1/users/{id}: get: - operationId: users_retrieve + operationId: api_v1_users_retrieve description: Fetch detailed information about an authenticated user. summary: Retrieve a user's information parameters: @@ -14708,7 +15982,7 @@ paths: $ref: '#/components/schemas/UserResponse' description: '' patch: - operationId: users_partial_update + operationId: api_v1_users_partial_update description: Partially update information about a user. summary: Update user information parameters: @@ -14743,7 +16017,7 @@ paths: $ref: '#/components/schemas/UserUpdateResponse' description: '' delete: - operationId: users_destroy + operationId: api_v1_users_destroy description: Remove the current user account from the system. summary: Delete the user account parameters: @@ -14763,7 +16037,7 @@ paths: description: No response body /api/v1/users/{id}/relationships/roles: post: - operationId: users_relationships_roles_create + operationId: api_v1_users_relationships_roles_create description: Add a new user-roles relationship to the system by providing the required user-roles details. summary: Create a new user-roles relationship @@ -14789,7 +16063,7 @@ paths: '400': description: Bad request (e.g., relationship already exists) patch: - operationId: users_relationships_roles_partial_update + operationId: api_v1_users_relationships_roles_partial_update description: Update the user-roles relationship information without affecting other fields. If the update would remove MANAGE_ACCOUNT from the last remaining user in the tenant, the API rejects the request with a 400 response. @@ -14814,7 +16088,7 @@ paths: '204': description: Relationship updated successfully delete: - operationId: users_relationships_roles_destroy + operationId: api_v1_users_relationships_roles_destroy description: Remove the user-roles relationship from the system by their ID. If removing MANAGE_ACCOUNT would take it away from the last remaining user in the tenant, the API rejects the request with a 400 response. Users also @@ -14830,7 +16104,7 @@ paths: description: Relationship deleted successfully /api/v1/users/{user_pk}/memberships: get: - operationId: users_memberships_list + operationId: api_v1_users_memberships_list description: Retrieve a list of all user memberships with options for filtering by various criteria. summary: List user memberships @@ -14943,7 +16217,7 @@ paths: description: '' /api/v1/users/{user_pk}/memberships/{id}: get: - operationId: users_memberships_retrieve + operationId: api_v1_users_memberships_retrieve description: Fetch detailed information about a specific user membership by their ID. summary: Retrieve membership data from the user @@ -14988,7 +16262,7 @@ paths: description: '' /api/v1/users/me: get: - operationId: users_me_retrieve + operationId: api_v1_users_me_retrieve description: Fetch detailed information about the authenticated user. summary: Retrieve the current user's information parameters: @@ -20271,18 +21545,22 @@ components: properties: okta_client_id: type: string - description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + description: Client ID of the Okta API Services app used for + OAuth 2.0 private-key JWT authentication. okta_private_key: type: string - description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + description: PEM-encoded private key whose matching public + key (JWK) is registered on the Okta service app. okta_scopes: type: array items: type: string - description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + description: OAuth scopes to request. Optional; defaults to + the minimum set required to run the currently enabled Okta + checks. required: - - okta_client_id - - okta_private_key + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: @@ -21314,7 +22592,7 @@ components: * `googleworkspace` - Google Workspace * `vercel` - Vercel * `okta` - Okta - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 uid: type: string title: Unique identifier for the provider, set by the provider @@ -21437,7 +22715,7 @@ components: - vercel - okta type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 description: |- Type of provider to create. @@ -21511,7 +22789,7 @@ components: - vercel - okta type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 description: |- Type of provider to create. @@ -22385,18 +23663,21 @@ components: properties: okta_client_id: type: string - description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + description: Client ID of the Okta API Services app used for OAuth + 2.0 private-key JWT authentication. okta_private_key: type: string - description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + description: PEM-encoded private key whose matching public key + (JWK) is registered on the Okta service app. okta_scopes: type: array items: type: string - description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + description: OAuth scopes to request. Optional; defaults to the + minimum set required to run the currently enabled Okta checks. required: - - okta_client_id - - okta_private_key + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: @@ -22827,18 +24108,22 @@ components: properties: okta_client_id: type: string - description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + description: Client ID of the Okta API Services app used for + OAuth 2.0 private-key JWT authentication. okta_private_key: type: string - description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + description: PEM-encoded private key whose matching public + key (JWK) is registered on the Okta service app. okta_scopes: type: array items: type: string - description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + description: OAuth scopes to request. Optional; defaults to + the minimum set required to run the currently enabled Okta + checks. required: - - okta_client_id - - okta_private_key + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: @@ -23279,18 +24564,21 @@ components: properties: okta_client_id: type: string - description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + description: Client ID of the Okta API Services app used for OAuth + 2.0 private-key JWT authentication. okta_private_key: type: string - description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + description: PEM-encoded private key whose matching public key + (JWK) is registered on the Okta service app. okta_scopes: type: array items: type: string - description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + description: OAuth scopes to request. Optional; defaults to the + minimum set required to run the currently enabled Okta checks. required: - - okta_client_id - - okta_private_key + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: diff --git a/api/src/backend/api/tests/test_attack_paths.py b/api/src/backend/api/tests/test_attack_paths.py index 30104a5a63..77bc01d255 100644 --- a/api/src/backend/api/tests/test_attack_paths.py +++ b/api/src/backend/api/tests/test_attack_paths.py @@ -92,7 +92,9 @@ def test_prepare_parameters_validates_cast( def test_execute_query_serializes_graph( - attack_paths_query_definition_factory, attack_paths_graph_stub_classes + attack_paths_query_definition_factory, + attack_paths_graph_stub_classes, + sink_backend_stub, ): definition = attack_paths_query_definition_factory( id="aws-rds", @@ -135,18 +137,17 @@ def test_execute_query_serializes_graph( database_name = "db-tenant-test-tenant-id" - with patch( - "api.attack_paths.views_helpers.graph_database.execute_read_query", - return_value=graph_result, - ) as mock_execute_read_query: - result = views_helpers.execute_query( - database_name, definition, parameters, provider_id=provider_id - ) + sink_backend_stub.execute_read_query.return_value = graph_result + result = views_helpers.execute_query( + database_name, + definition, + parameters, + provider_id=provider_id, + scan=MagicMock(is_migrated=False, sink_backend="neo4j"), + ) - mock_execute_read_query.assert_called_once_with( - database=database_name, - cypher=definition.cypher, - parameters=parameters, + sink_backend_stub.execute_read_query.assert_called_once_with( + database_name, definition.cypher, parameters ) assert result["nodes"][0]["id"] == "node-1" assert result["nodes"][0]["properties"]["complex"]["items"][0] == "value" @@ -155,6 +156,7 @@ def test_execute_query_serializes_graph( def test_execute_query_wraps_graph_errors( attack_paths_query_definition_factory, + sink_backend_stub, ): definition = attack_paths_query_definition_factory( id="aws-rds", @@ -167,16 +169,17 @@ def test_execute_query_wraps_graph_errors( database_name = "db-tenant-test-tenant-id" parameters = {"provider_uid": "123"} - with ( - patch( - "api.attack_paths.views_helpers.graph_database.execute_read_query", - side_effect=graph_database.GraphDatabaseQueryException("boom"), - ), - patch("api.attack_paths.views_helpers.logger") as mock_logger, - ): + sink_backend_stub.execute_read_query.side_effect = ( + graph_database.GraphDatabaseQueryException("boom") + ) + with patch("api.attack_paths.views_helpers.logger") as mock_logger: with pytest.raises(APIException): views_helpers.execute_query( - database_name, definition, parameters, provider_id="test-provider-123" + database_name, + definition, + parameters, + provider_id="test-provider-123", + scan=MagicMock(is_migrated=False, sink_backend="neo4j"), ) mock_logger.error.assert_called_once() @@ -184,6 +187,7 @@ def test_execute_query_wraps_graph_errors( def test_execute_query_raises_permission_denied_on_read_only( attack_paths_query_definition_factory, + sink_backend_stub, ): definition = attack_paths_query_definition_factory( id="aws-rds", @@ -196,17 +200,20 @@ def test_execute_query_raises_permission_denied_on_read_only( database_name = "db-tenant-test-tenant-id" parameters = {"provider_uid": "123"} - with patch( - "api.attack_paths.views_helpers.graph_database.execute_read_query", - side_effect=graph_database.WriteQueryNotAllowedException( + sink_backend_stub.execute_read_query.side_effect = ( + graph_database.WriteQueryNotAllowedException( message="Read query not allowed", code="Neo.ClientError.Statement.AccessMode", - ), - ): - with pytest.raises(PermissionDenied): - views_helpers.execute_query( - database_name, definition, parameters, provider_id="test-provider-123" - ) + ) + ) + with pytest.raises(PermissionDenied): + views_helpers.execute_query( + database_name, + definition, + parameters, + provider_id="test-provider-123", + scan=MagicMock(is_migrated=False, sink_backend="neo4j"), + ) def test_serialize_graph_filters_by_provider_label(attack_paths_graph_stub_classes): @@ -440,6 +447,7 @@ def test_normalize_custom_query_payload_passthrough_for_flat_dict(): def test_execute_custom_query_serializes_graph( attack_paths_graph_stub_classes, + sink_backend_stub, ): provider_id = "test-provider-123" plabel = get_provider_label(provider_id) @@ -453,50 +461,73 @@ def test_execute_custom_query_serializes_graph( graph_result.nodes = [node_1, node_2] graph_result.relationships = [relationship] - with patch( - "api.attack_paths.views_helpers.graph_database.execute_read_query", - return_value=graph_result, - ) as mock_execute: - result = views_helpers.execute_custom_query( - "db-tenant-test", "MATCH (n) RETURN n", provider_id - ) + sink_backend_stub.execute_read_query.return_value = graph_result + result = views_helpers.execute_custom_query( + "db-tenant-test", + "MATCH (n) RETURN n", + provider_id, + scan=MagicMock(is_migrated=False, sink_backend="neo4j"), + ) - mock_execute.assert_called_once() - call_kwargs = mock_execute.call_args[1] - assert call_kwargs["database"] == "db-tenant-test" + sink_backend_stub.execute_read_query.assert_called_once() + call_args = sink_backend_stub.execute_read_query.call_args[0] + assert call_args[0] == "db-tenant-test" # The cypher is rewritten with the provider label injection - assert plabel in call_kwargs["cypher"] + assert plabel in call_args[1] assert len(result["nodes"]) == 2 assert result["relationships"][0]["label"] == "OWNS" assert result["truncated"] is False assert result["total_nodes"] == 2 -def test_execute_custom_query_raises_permission_denied_on_write(): +def test_execute_custom_query_adds_timeout_for_neptune_scan(sink_backend_stub): + graph_result = MagicMock() + graph_result.nodes = [] + graph_result.relationships = [] + sink_backend_stub.execute_read_query.return_value = graph_result + with patch( - "api.attack_paths.views_helpers.graph_database.execute_read_query", - side_effect=graph_database.WriteQueryNotAllowedException( + "api.attack_paths.views_helpers.sink_module.get_backend_for_scan", + return_value=sink_backend_stub, + ): + views_helpers.execute_custom_query( + "db-tenant-test", + "MATCH (n) RETURN n", + "provider-1", + scan=MagicMock(is_migrated=True, sink_backend="neptune"), + ) + + cypher = sink_backend_stub.execute_read_query.call_args[0][1] + assert cypher.startswith("USING QUERY:TIMEOUTMILLISECONDS") + + +def test_execute_custom_query_raises_permission_denied_on_write(sink_backend_stub): + sink_backend_stub.execute_read_query.side_effect = ( + graph_database.WriteQueryNotAllowedException( message="Read query not allowed", code="Neo.ClientError.Statement.AccessMode", - ), - ): - with pytest.raises(PermissionDenied): - views_helpers.execute_custom_query( - "db-tenant-test", "CREATE (n) RETURN n", "provider-1" - ) + ) + ) + with pytest.raises(PermissionDenied): + views_helpers.execute_custom_query( + "db-tenant-test", + "CREATE (n) RETURN n", + "provider-1", + scan=MagicMock(is_migrated=False, sink_backend="neo4j"), + ) -def test_execute_custom_query_wraps_graph_errors(): - with ( - patch( - "api.attack_paths.views_helpers.graph_database.execute_read_query", - side_effect=graph_database.GraphDatabaseQueryException("boom"), - ), - patch("api.attack_paths.views_helpers.logger") as mock_logger, - ): +def test_execute_custom_query_wraps_graph_errors(sink_backend_stub): + sink_backend_stub.execute_read_query.side_effect = ( + graph_database.GraphDatabaseQueryException("boom") + ) + with patch("api.attack_paths.views_helpers.logger") as mock_logger: with pytest.raises(APIException): views_helpers.execute_custom_query( - "db-tenant-test", "MATCH (n) RETURN n", "provider-1" + "db-tenant-test", + "MATCH (n) RETURN n", + "provider-1", + scan=MagicMock(is_migrated=False, sink_backend="neo4j"), ) mock_logger.error.assert_called_once() @@ -561,13 +592,33 @@ def test_truncate_graph_empty_graph(): @pytest.fixture def mock_neo4j_session(): - """Mock the Neo4j driver so execute_read_query uses a fake session.""" + """Install a Neo4jSink with a mocked Bolt driver into the sink factory. + + The yielded mock is the `neo4j.Session` that the Neo4jSink will obtain via + `driver.session(...)`. Tests configure `mock_neo4j_session.execute_read` + return values / side effects to exercise the read-mode error translation + path on the real `Neo4jSink.execute_read_query` and `get_session` code. + """ + from api.attack_paths.sink import factory + from api.attack_paths.sink.neo4j import Neo4jSink + mock_session = MagicMock(spec=neo4j.Session) mock_driver = MagicMock(spec=neo4j.Driver) mock_driver.session.return_value = mock_session - with patch("api.attack_paths.database.get_driver", return_value=mock_driver): + sink = Neo4jSink() + sink._driver = mock_driver + + previous_backend = factory._backend + previous_secondary = dict(factory._secondary_backends) + factory._backend = sink + factory._secondary_backends.clear() + try: yield mock_session + finally: + factory._backend = previous_backend + factory._secondary_backends.clear() + factory._secondary_backends.update(previous_secondary) def test_execute_read_query_succeeds_with_select(mock_neo4j_session): @@ -663,16 +714,20 @@ def test_execute_read_query_rejects_apoc_real_create(mock_neo4j_session, cypher) @pytest.fixture def mock_schema_session(): - """Mock get_session for cartography schema tests.""" + """Mock the routed sink backend session for cartography schema tests.""" mock_result = MagicMock() mock_session = MagicMock() mock_session.run.return_value = mock_result + mock_backend = MagicMock() with patch( - "api.attack_paths.views_helpers.graph_database.get_session" - ) as mock_get_session: - mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) - mock_get_session.return_value.__exit__ = MagicMock(return_value=False) + "api.attack_paths.views_helpers.sink_module.get_backend_for_scan", + return_value=mock_backend, + ): + mock_backend.get_session.return_value.__enter__ = MagicMock( + return_value=mock_session + ) + mock_backend.get_session.return_value.__exit__ = MagicMock(return_value=False) yield mock_session, mock_result @@ -683,7 +738,9 @@ def test_get_cartography_schema_returns_urls(mock_schema_session): "module_version": "0.129.0", } - result = views_helpers.get_cartography_schema("db-tenant-test", "provider-123") + result = views_helpers.get_cartography_schema( + "db-tenant-test", "provider-123", MagicMock(sink_backend="neo4j") + ) mock_session.run.assert_called_once() assert result["id"] == "aws-0.129.0" @@ -699,7 +756,9 @@ def test_get_cartography_schema_returns_none_when_no_data(mock_schema_session): _, mock_result = mock_schema_session mock_result.single.return_value = None - result = views_helpers.get_cartography_schema("db-tenant-test", "provider-123") + result = views_helpers.get_cartography_schema( + "db-tenant-test", "provider-123", MagicMock(sink_backend="neo4j") + ) assert result is None @@ -721,21 +780,29 @@ def test_get_cartography_schema_extracts_provider( "module_version": "1.0.0", } - result = views_helpers.get_cartography_schema("db-tenant-test", "provider-123") + result = views_helpers.get_cartography_schema( + "db-tenant-test", "provider-123", MagicMock(sink_backend="neo4j") + ) assert result["id"] == f"{expected_provider}-1.0.0" assert result["provider"] == expected_provider def test_get_cartography_schema_wraps_database_error(): + mock_backend = MagicMock() + mock_backend.get_session.side_effect = graph_database.GraphDatabaseQueryException( + "boom" + ) with ( patch( - "api.attack_paths.views_helpers.graph_database.get_session", - side_effect=graph_database.GraphDatabaseQueryException("boom"), + "api.attack_paths.views_helpers.sink_module.get_backend_for_scan", + return_value=mock_backend, ), patch("api.attack_paths.views_helpers.logger") as mock_logger, ): with pytest.raises(APIException): - views_helpers.get_cartography_schema("db-tenant-test", "provider-123") + views_helpers.get_cartography_schema( + "db-tenant-test", "provider-123", MagicMock(sink_backend="neo4j") + ) mock_logger.error.assert_called_once() 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 bf37daf5ee..c4aca45928 100644 --- a/api/src/backend/api/tests/test_attack_paths_database.py +++ b/api/src/backend/api/tests/test_attack_paths_database.py @@ -1,623 +1,240 @@ -""" -Tests for Neo4j database lazy initialization. +"""Tests for the attack-paths database facade. -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. +After the Neptune port, `api.attack_paths.database` is a thin routing shim +over `api.attack_paths.ingest` (cartography temp DB, always Neo4j) and +`api.attack_paths.sink` (configurable Neo4j or Neptune). The facade's +contract is routing by database-name prefix and the public exception +hierarchy; sink-internal behavior is exercised in `test_sink.py`. """ -import threading from unittest.mock import MagicMock, patch import api.attack_paths.database as db_module -import neo4j -import neo4j.exceptions import pytest -class TestLazyInitialization: - """Test that Neo4j driver is initialized lazily on first use.""" - - @pytest.fixture(autouse=True) - def reset_module_state(self): - """Reset module-level singleton state before each test.""" - original_driver = db_module._driver - - db_module._driver = None - - yield - - db_module._driver = original_driver - - def test_driver_not_initialized_at_import(self): - """Driver should be None after module import (no eager connection).""" - assert db_module._driver is None - - @patch("api.attack_paths.database.settings") - @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") - def test_init_driver_creates_connection_on_first_call( - self, mock_driver_factory, mock_settings - ): - """init_driver() should create connection only when called.""" - mock_driver = MagicMock() - mock_driver_factory.return_value = mock_driver - mock_settings.DATABASES = { - "neo4j": { - "HOST": "localhost", - "PORT": 7687, - "USER": "neo4j", - "PASSWORD": "password", - } - } - - assert db_module._driver is None - - result = db_module.init_driver() - - mock_driver_factory.assert_called_once() - mock_driver.verify_connectivity.assert_called_once() - 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") +class TestDatabaseNameHelper: + def test_tenant_name_lowercases_uuid(self): + assert ( + db_module.get_database_name("ABC-123", temporary=False) + == "db-tenant-abc-123" ) - 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( - self, mock_driver_factory, mock_settings - ): - """Subsequent calls should return cached driver without reconnecting.""" - mock_driver = MagicMock() - mock_driver_factory.return_value = mock_driver - mock_settings.DATABASES = { - "neo4j": { - "HOST": "localhost", - "PORT": 7687, - "USER": "neo4j", - "PASSWORD": "password", - } - } - - first_result = db_module.init_driver() - second_result = db_module.init_driver() - third_result = db_module.init_driver() - - # Only one connection attempt - assert mock_driver_factory.call_count == 1 - assert mock_driver.verify_connectivity.call_count == 1 - - # All calls return same instance - assert first_result is second_result is third_result - - @patch("api.attack_paths.database.settings") - @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") - def test_get_driver_delegates_to_init_driver( - self, mock_driver_factory, mock_settings - ): - """get_driver() should use init_driver() for lazy initialization.""" - mock_driver = MagicMock() - mock_driver_factory.return_value = mock_driver - mock_settings.DATABASES = { - "neo4j": { - "HOST": "localhost", - "PORT": 7687, - "USER": "neo4j", - "PASSWORD": "password", - } - } - - result = db_module.get_driver() - - assert result is mock_driver - mock_driver_factory.assert_called_once() + def test_temporary_name_uses_tmp_scan_prefix(self): + assert ( + db_module.get_database_name("XYZ-789", temporary=True) + == "db-tmp-scan-xyz-789" + ) -class TestConnectionAcquisitionTimeout: - """Test that the connection acquisition timeout is configurable.""" +class TestExceptionHierarchy: + """`tasks/` and `api/v1/views.py` import these from the facade.""" - @pytest.fixture(autouse=True) - def reset_module_state(self): - original_driver = db_module._driver - original_acq_timeout = db_module.CONN_ACQUISITION_TIMEOUT - original_conn_timeout = db_module.CONNECTION_TIMEOUT + def test_write_query_is_graph_database_exception(self): + assert issubclass( + db_module.WriteQueryNotAllowedException, + db_module.GraphDatabaseQueryException, + ) - db_module._driver = None + def test_client_statement_is_graph_database_exception(self): + assert issubclass( + db_module.ClientStatementException, db_module.GraphDatabaseQueryException + ) - yield + def test_exception_str_includes_code_when_set(self): + exc = db_module.GraphDatabaseQueryException( + message="boom", code="Neo.ClientError.X.Y" + ) + assert str(exc) == "Neo.ClientError.X.Y: boom" - db_module._driver = original_driver - 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 the configured timeouts to the neo4j driver.""" - mock_driver_factory.return_value = MagicMock() - mock_settings.DATABASES = { - "neo4j": { - "HOST": "localhost", - "PORT": 7687, - "USER": "neo4j", - "PASSWORD": "password", - } - } - 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 + def test_exception_str_falls_back_to_message_without_code(self): + exc = db_module.GraphDatabaseQueryException(message="boom") + assert str(exc) == "boom" -class TestAtexitRegistration: - """Test that atexit cleanup handler is registered correctly.""" +class TestExecuteReadQueryRoutes: + def test_execute_read_query_delegates_to_sink(self, sink_backend_stub): + sink_backend_stub.execute_read_query.return_value = "graph" - @pytest.fixture(autouse=True) - def reset_module_state(self): - """Reset module-level singleton state before each test.""" - original_driver = db_module._driver + result = db_module.execute_read_query( + "db-tenant-abc", "MATCH (n) RETURN n", {"provider_uid": "123"} + ) - db_module._driver = None + sink_backend_stub.execute_read_query.assert_called_once_with( + "db-tenant-abc", "MATCH (n) RETURN n", {"provider_uid": "123"} + ) + assert result == "graph" - yield + def test_execute_read_query_defaults_parameters_to_none(self, sink_backend_stub): + db_module.execute_read_query("db-tenant-abc", "MATCH (n) RETURN n") - db_module._driver = original_driver - - @patch("api.attack_paths.database.settings") - @patch("api.attack_paths.database.atexit.register") - @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") - def test_atexit_registered_on_first_init( - self, mock_driver_factory, mock_atexit_register, mock_settings - ): - """atexit.register should be called on first initialization.""" - mock_driver_factory.return_value = MagicMock() - mock_settings.DATABASES = { - "neo4j": { - "HOST": "localhost", - "PORT": 7687, - "USER": "neo4j", - "PASSWORD": "password", - } - } - - db_module.init_driver() - - mock_atexit_register.assert_called_once_with(db_module.close_driver) - - @patch("api.attack_paths.database.settings") - @patch("api.attack_paths.database.atexit.register") - @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") - def test_atexit_registered_only_once( - self, mock_driver_factory, mock_atexit_register, mock_settings - ): - """atexit.register should only be called once across multiple inits. - - The double-checked locking on _driver ensures the atexit registration - block only executes once (when _driver is first created). - """ - mock_driver_factory.return_value = MagicMock() - mock_settings.DATABASES = { - "neo4j": { - "HOST": "localhost", - "PORT": 7687, - "USER": "neo4j", - "PASSWORD": "password", - } - } - - db_module.init_driver() - db_module.init_driver() - db_module.init_driver() - - # Only registered once because subsequent calls hit the fast path - assert mock_atexit_register.call_count == 1 + sink_backend_stub.execute_read_query.assert_called_once_with( + "db-tenant-abc", "MATCH (n) RETURN n", None + ) -class TestCloseDriver: - """Test driver cleanup functionality.""" +class TestScanDatabaseAvailability: + def test_verify_scan_databases_available_checks_ingest_and_sink(self): + with ( + patch("api.attack_paths.database.ingest") as mock_ingest, + patch("api.attack_paths.database.get_driver") as mock_get_driver, + ): + db_module.verify_scan_databases_available() - @pytest.fixture(autouse=True) - def reset_module_state(self): - """Reset module-level singleton state before each test.""" - original_driver = db_module._driver + mock_ingest.get_driver.return_value.verify_connectivity.assert_called_once_with() + mock_get_driver.return_value.verify_connectivity.assert_called_once_with() - db_module._driver = None - - yield - - db_module._driver = original_driver - - def test_close_driver_closes_and_clears_driver(self): - """close_driver() should close the driver and set it to None.""" - mock_driver = MagicMock() - db_module._driver = mock_driver - - db_module.close_driver() - - mock_driver.close.assert_called_once() - assert db_module._driver is None - - def test_close_driver_handles_none_driver(self): - """close_driver() should handle case where driver is None.""" - db_module._driver = None - - # Should not raise - db_module.close_driver() - - assert db_module._driver is None - - def test_close_driver_clears_driver_even_on_close_error(self): - """Driver should be cleared even if close() raises an exception.""" - mock_driver = MagicMock() - mock_driver.close.side_effect = Exception("Connection error") - db_module._driver = mock_driver - - with pytest.raises(Exception, match="Connection error"): - db_module.close_driver() - - # Driver should still be cleared - assert db_module._driver is None - - -class TestExecuteReadQuery: - """Test read query execution helper.""" - - def test_execute_read_query_calls_read_session_and_returns_result(self): - tx = MagicMock() - expected_graph = MagicMock() - run_result = MagicMock() - run_result.graph.return_value = expected_graph - tx.run.return_value = run_result - - session = MagicMock() - - def execute_read_side_effect(fn): - return fn(tx) - - session.execute_read.side_effect = execute_read_side_effect - - session_ctx = MagicMock() - session_ctx.__enter__.return_value = session - session_ctx.__exit__.return_value = False - - with patch( - "api.attack_paths.database.get_session", - return_value=session_ctx, - ) as mock_get_session: - result = db_module.execute_read_query( - "db-tenant-test-tenant-id", - "MATCH (n) RETURN n", - {"provider_uid": "123"}, + def test_verify_scan_databases_available_raises_when_ingest_is_down(self): + with ( + patch("api.attack_paths.database.ingest") as mock_ingest, + patch("api.attack_paths.database.get_driver"), + ): + mock_ingest.get_driver.return_value.verify_connectivity.side_effect = ( + RuntimeError("ingest down") ) - mock_get_session.assert_called_once_with( - "db-tenant-test-tenant-id", - default_access_mode=neo4j.READ_ACCESS, + with pytest.raises(RuntimeError) as exc: + db_module.verify_scan_databases_available() + + assert "Attack Paths graph database unavailable before scan start" in str( + exc.value ) - session.execute_read.assert_called_once() - tx.run.assert_called_once_with( - "MATCH (n) RETURN n", - {"provider_uid": "123"}, - timeout=db_module.READ_QUERY_TIMEOUT_SECONDS, - ) - run_result.graph.assert_called_once_with() - assert result is expected_graph + assert "ingest Neo4j: ingest down" in str(exc.value) - def test_execute_read_query_defaults_parameters_to_empty_dict(self): - tx = MagicMock() - run_result = MagicMock() - run_result.graph.return_value = MagicMock() - tx.run.return_value = run_result + def test_verify_scan_databases_available_raises_when_sink_is_down(self, settings): + settings.ATTACK_PATHS_SINK_DATABASE = "neptune" - session = MagicMock() - session.execute_read.side_effect = lambda fn: fn(tx) - - session_ctx = MagicMock() - session_ctx.__enter__.return_value = session - session_ctx.__exit__.return_value = False - - with patch( - "api.attack_paths.database.get_session", - return_value=session_ctx, + with ( + patch("api.attack_paths.database.ingest"), + patch("api.attack_paths.database.get_driver") as mock_get_driver, ): - db_module.execute_read_query( - "db-tenant-test-tenant-id", - "MATCH (n) RETURN n", + mock_get_driver.return_value.verify_connectivity.side_effect = RuntimeError( + "writer down" ) - tx.run.assert_called_once_with( - "MATCH (n) RETURN n", - {}, - timeout=db_module.READ_QUERY_TIMEOUT_SECONDS, - ) - run_result.graph.assert_called_once_with() + with pytest.raises(RuntimeError) as exc: + db_module.verify_scan_databases_available() + assert "sink neptune: writer down" in str(exc.value) -class TestGetSessionReadOnly: - """Test that get_session translates Neo4j read-mode errors.""" + def test_verify_scan_databases_available_reports_both_failures(self, settings): + settings.ATTACK_PATHS_SINK_DATABASE = "neo4j" - @pytest.fixture(autouse=True) - def reset_module_state(self): - original_driver = db_module._driver - db_module._driver = None - yield - db_module._driver = original_driver - - @pytest.mark.parametrize( - "neo4j_code", - [ - "Neo.ClientError.Statement.AccessMode", - "Neo.ClientError.Procedure.ProcedureNotFound", - ], - ) - def test_get_session_raises_write_query_not_allowed(self, neo4j_code): - """Read-mode Neo4j errors should raise `WriteQueryNotAllowedException`.""" - mock_session = MagicMock() - neo4j_error = neo4j.exceptions.Neo4jError._hydrate_neo4j( - code=neo4j_code, - message="Write operations are not allowed", - ) - mock_session.run.side_effect = neo4j_error - - mock_driver = MagicMock() - mock_driver.session.return_value = mock_session - db_module._driver = mock_driver - - with pytest.raises(db_module.WriteQueryNotAllowedException): - with db_module.get_session( - default_access_mode=neo4j.READ_ACCESS - ) as session: - session.run("CREATE (n) RETURN n") - - def test_get_session_raises_generic_exception_for_other_errors(self): - """Non-read-mode Neo4j errors should raise GraphDatabaseQueryException.""" - mock_session = MagicMock() - neo4j_error = neo4j.exceptions.Neo4jError._hydrate_neo4j( - code="Neo.ClientError.Statement.SyntaxError", - message="Invalid syntax", - ) - mock_session.run.side_effect = neo4j_error - - mock_driver = MagicMock() - mock_driver.session.return_value = mock_session - db_module._driver = mock_driver - - with pytest.raises(db_module.GraphDatabaseQueryException): - with db_module.get_session( - default_access_mode=neo4j.READ_ACCESS - ) as session: - session.run("INVALID CYPHER") - - -class TestThreadSafety: - """Test thread-safe initialization.""" - - @pytest.fixture(autouse=True) - def reset_module_state(self): - """Reset module-level singleton state before each test.""" - original_driver = db_module._driver - - db_module._driver = None - - yield - - db_module._driver = original_driver - - @patch("api.attack_paths.database.settings") - @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") - def test_concurrent_init_creates_single_driver( - self, mock_driver_factory, mock_settings - ): - """Multiple threads calling init_driver() should create only one driver.""" - mock_driver = MagicMock() - mock_driver_factory.return_value = mock_driver - mock_settings.DATABASES = { - "neo4j": { - "HOST": "localhost", - "PORT": 7687, - "USER": "neo4j", - "PASSWORD": "password", - } - } - - results = [] - errors = [] - - def call_init(): - try: - result = db_module.init_driver() - results.append(result) - except Exception as e: - errors.append(e) - - threads = [threading.Thread(target=call_init) for _ in range(10)] - - for t in threads: - t.start() - for t in threads: - t.join() - - assert not errors, f"Threads raised errors: {errors}" - - # Only one driver created - assert mock_driver_factory.call_count == 1 - - # All threads got the same driver instance - assert all(r is mock_driver for r in results) - assert len(results) == 10 - - -class TestHasProviderData: - """Test has_provider_data helper for checking provider nodes in Neo4j.""" - - def test_returns_true_when_nodes_exist(self): - mock_session = MagicMock() - mock_result = MagicMock() - mock_result.single.return_value = MagicMock() # non-None record - mock_session.run.return_value = mock_result - - session_ctx = MagicMock() - session_ctx.__enter__.return_value = mock_session - session_ctx.__exit__.return_value = False - - with patch( - "api.attack_paths.database.get_session", - return_value=session_ctx, + with ( + patch("api.attack_paths.database.ingest") as mock_ingest, + patch("api.attack_paths.database.get_driver") as mock_get_driver, ): - assert db_module.has_provider_data("db-tenant-abc", "provider-123") is True - - mock_session.run.assert_called_once() - - def test_returns_false_when_no_nodes(self): - mock_session = MagicMock() - mock_result = MagicMock() - mock_result.single.return_value = None - mock_session.run.return_value = mock_result - - session_ctx = MagicMock() - session_ctx.__enter__.return_value = mock_session - session_ctx.__exit__.return_value = False - - with patch( - "api.attack_paths.database.get_session", - return_value=session_ctx, - ): - assert db_module.has_provider_data("db-tenant-abc", "provider-123") is False - - def test_returns_false_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.has_provider_data("db-tenant-gone", "provider-123") is False + mock_ingest.get_driver.return_value.verify_connectivity.side_effect = ( + RuntimeError("ingest down") + ) + mock_get_driver.return_value.verify_connectivity.side_effect = RuntimeError( + "sink down" ) - 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 pytest.raises(RuntimeError) as exc: + db_module.verify_scan_databases_available() + + assert "ingest Neo4j: ingest down" in str(exc.value) + assert "sink neo4j: sink down" in str(exc.value) + + +class TestSinkOperationsDelegation: + def test_has_provider_data_delegates_to_sink(self, sink_backend_stub): + sink_backend_stub.has_provider_data.return_value = True + + assert db_module.has_provider_data("db-tenant-abc", "provider-123") is True + sink_backend_stub.has_provider_data.assert_called_once_with( + "db-tenant-abc", "provider-123" ) - with patch( - "api.attack_paths.database.get_session", - return_value=session_ctx, - ): - with pytest.raises(db_module.GraphDatabaseQueryException): - db_module.has_provider_data("db-tenant-abc", "provider-123") + def test_drop_subgraph_delegates_to_sink(self, sink_backend_stub): + sink_backend_stub.drop_subgraph.return_value = 42 - -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", + assert db_module.drop_subgraph("db-tenant-abc", "provider-123") == 42 + sink_backend_stub.drop_subgraph.assert_called_once_with( + "db-tenant-abc", "provider-123" ) - 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", - ) +class TestRoutingByDatabasePrefix: + """`db-tmp-scan-*` and `None` route to ingest; everything else to sink.""" - 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") + def test_create_database_routes_temp_to_ingest(self, sink_backend_stub): + with patch("api.attack_paths.database.ingest") as mock_ingest: + db_module.create_database("db-tmp-scan-uuid-1") + + mock_ingest.create_database.assert_called_once_with("db-tmp-scan-uuid-1") + sink_backend_stub.create_database.assert_not_called() + + def test_create_database_routes_tenant_to_sink(self, sink_backend_stub): + with patch("api.attack_paths.database.ingest") as mock_ingest: + db_module.create_database("db-tenant-abc") + + sink_backend_stub.create_database.assert_called_once_with("db-tenant-abc") + mock_ingest.create_database.assert_not_called() + + def test_drop_database_routes_temp_to_ingest(self, sink_backend_stub): + with patch("api.attack_paths.database.ingest") as mock_ingest: + db_module.drop_database("db-tmp-scan-uuid-1") + + mock_ingest.drop_database.assert_called_once_with("db-tmp-scan-uuid-1") + sink_backend_stub.drop_database.assert_not_called() + + def test_drop_database_routes_tenant_to_sink(self, sink_backend_stub): + with patch("api.attack_paths.database.ingest") as mock_ingest: + db_module.drop_database("db-tenant-abc") + + sink_backend_stub.drop_database.assert_called_once_with("db-tenant-abc") + mock_ingest.drop_database.assert_not_called() + + def test_clear_cache_routes_temp_to_ingest(self, sink_backend_stub): + with patch("api.attack_paths.database.ingest") as mock_ingest: + db_module.clear_cache("db-tmp-scan-uuid-1") + + mock_ingest.clear_cache.assert_called_once_with("db-tmp-scan-uuid-1") + sink_backend_stub.clear_cache.assert_not_called() + + def test_clear_cache_routes_tenant_to_sink(self, sink_backend_stub): + with patch("api.attack_paths.database.ingest") as mock_ingest: + db_module.clear_cache("db-tenant-abc") + + sink_backend_stub.clear_cache.assert_called_once_with("db-tenant-abc") + mock_ingest.clear_cache.assert_not_called() + + def test_get_session_routes_temp_to_ingest(self, sink_backend_stub): + sentinel = MagicMock() + with patch("api.attack_paths.database.ingest") as mock_ingest: + mock_ingest.get_session.return_value = sentinel + + result = db_module.get_session("db-tmp-scan-uuid-1") + + assert result is sentinel + mock_ingest.get_session.assert_called_once() + sink_backend_stub.get_session.assert_not_called() + + def test_get_session_routes_none_to_ingest(self, sink_backend_stub): + sentinel = MagicMock() + with patch("api.attack_paths.database.ingest") as mock_ingest: + mock_ingest.get_session.return_value = sentinel + + result = db_module.get_session(None) + + assert result is sentinel + sink_backend_stub.get_session.assert_not_called() + + def test_get_ingest_uri_delegates_to_ingest(self, sink_backend_stub): + with patch("api.attack_paths.database.ingest") as mock_ingest: + mock_ingest.get_uri.return_value = "bolt://neo4j:7687" + + assert db_module.get_ingest_uri() == "bolt://neo4j:7687" + + mock_ingest.get_uri.assert_called_once_with() + + def test_get_session_routes_tenant_to_sink(self, sink_backend_stub): + sentinel = MagicMock() + sink_backend_stub.get_session.return_value = sentinel + with patch("api.attack_paths.database.ingest") as mock_ingest: + result = db_module.get_session("db-tenant-abc") + + assert result is sentinel + mock_ingest.get_session.assert_not_called() diff --git a/api/src/backend/api/tests/test_authentication.py b/api/src/backend/api/tests/test_authentication.py index fa72b395b3..d05a55ce5a 100644 --- a/api/src/backend/api/tests/test_authentication.py +++ b/api/src/backend/api/tests/test_authentication.py @@ -7,6 +7,7 @@ import pytest from api.authentication import SSEAuthentication, TenantAPIKeyAuthentication from api.db_router import MainRouter from api.models import TenantAPIKey +from django.db.models.query import QuerySet from django.test import RequestFactory from rest_framework.exceptions import AuthenticationFailed @@ -64,6 +65,54 @@ class TestTenantAPIKeyAuthentication: # Verify the manager was restored assert TenantAPIKey.objects == original_manager + def test_authenticate_credentials_keeps_manager_during_lookup( + self, auth_backend, api_keys_fixture, request_factory + ): + """Authentication must not expose a QuerySet as the model manager.""" + api_key = api_keys_fixture[0] + raw_key = api_key._raw_key + _, encrypted_key = raw_key.split(TenantAPIKey.objects.separator, 1) + + original_get = QuerySet.get + manager_has_create_api_key = [] + + def observe_manager(queryset, *args, **kwargs): + manager_has_create_api_key.append( + hasattr(TenantAPIKey.objects, "create_api_key") + ) + return original_get(queryset, *args, **kwargs) + + request = request_factory.get("/") + + with patch.object(QuerySet, "get", observe_manager): + auth_backend._authenticate_credentials(request, encrypted_key) + + assert manager_has_create_api_key + assert all(manager_has_create_api_key) + + @pytest.mark.parametrize( + "payload", + [ + {"_pk": str(uuid4()), "_exp": "not-a-timestamp"}, + { + "_pk": "not-a-uuid", + "_exp": (datetime.now(UTC) + timedelta(days=1)).timestamp(), + }, + {"_pk": str(uuid4()), "_exp": True}, + ], + ) + def test_authenticate_credentials_rejects_malformed_payloads( + self, auth_backend, request_factory, payload + ): + """Malformed decrypted payloads fail as authentication errors.""" + request = request_factory.get("/") + encrypted_key = auth_backend.key_crypto.generate(payload) + + with pytest.raises(AuthenticationFailed) as exc_info: + auth_backend._authenticate_credentials(request, encrypted_key) + + assert str(exc_info.value.detail) == "Invalid API Key." + def test_authenticate_credentials_restores_manager_on_exception( self, auth_backend, request_factory ): diff --git a/api/src/backend/api/tests/test_cypher_sanitizer.py b/api/src/backend/api/tests/test_cypher_sanitizer.py index 6caca47a56..c0d4f9b7ff 100644 --- a/api/src/backend/api/tests/test_cypher_sanitizer.py +++ b/api/src/backend/api/tests/test_cypher_sanitizer.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest from api.attack_paths.cypher_sanitizer import ( + inject_label, inject_provider_label, validate_custom_query, ) @@ -21,6 +22,13 @@ def _inject(cypher: str) -> str: return inject_provider_label(cypher, PROVIDER_ID) +def test_generic_inject_label_reuses_provider_injection_pipeline(): + result = inject_label("MATCH (n:AWSRole)--(m) RETURN n, m", "_Tenant_test") + + assert "(n:AWSRole:_Tenant_test)" in result + assert "(m:_Tenant_test)" in result + + # --------------------------------------------------------------------------- # Pass A - Labeled node patterns (all clauses) # --------------------------------------------------------------------------- diff --git a/api/src/backend/api/tests/test_health.py b/api/src/backend/api/tests/test_health.py index f3a7bb34a4..b7cd20f697 100644 --- a/api/src/backend/api/tests/test_health.py +++ b/api/src/backend/api/tests/test_health.py @@ -67,7 +67,7 @@ class TestLivenessEndpoint: with ( patch("api.health._probe_postgres") as mock_pg, patch("api.health._probe_valkey") as mock_vk, - patch("api.health._probe_neo4j") as mock_neo, + patch("api.health._probe_graph_db") as mock_neo, ): response = api_client.get(reverse("health-live")) @@ -83,14 +83,14 @@ class TestReadinessEndpoint: return ( patch("api.health._probe_postgres", return_value=None), patch("api.health._probe_valkey", return_value=None), - patch("api.health._probe_neo4j", return_value=None), + patch("api.health._probe_graph_db", return_value=None), ) def test_returns_200_and_pass_when_all_dependencies_healthy(self, api_client): with ( patch("api.health._probe_postgres"), patch("api.health._probe_valkey"), - patch("api.health._probe_neo4j"), + patch("api.health._probe_graph_db"), ): response = api_client.get(reverse("health-ready")) @@ -107,7 +107,7 @@ class TestReadinessEndpoint: assert set(body["checks"].keys()) == { "postgres:responseTime", "valkey:responseTime", - "neo4j:responseTime", + "graphdb:responseTime", } for key in body["checks"]: entries = body["checks"][key] @@ -122,6 +122,23 @@ class TestReadinessEndpoint: # `output` must not leak when the check passed. assert "output" not in entry + @pytest.mark.parametrize("sink", ["neo4j", "neptune"]) + def test_graphdb_component_id_reflects_active_sink(self, api_client, sink): + from django.test import override_settings + + with ( + override_settings(ATTACK_PATHS_SINK_DATABASE=sink), + patch("api.health._probe_postgres"), + patch("api.health._probe_valkey"), + patch("api.health._probe_graph_db"), + ): + response = api_client.get(reverse("health-ready")) + + assert response.status_code == status.HTTP_200_OK + entry = response.json()["checks"]["graphdb:responseTime"][0] + # Stable key, but the concrete store is named in componentId. + assert entry["componentId"] == sink + def test_returns_503_and_fail_when_postgres_is_down(self, api_client): with ( patch( @@ -129,7 +146,7 @@ class TestReadinessEndpoint: side_effect=RuntimeError("connection refused"), ), patch("api.health._probe_valkey"), - patch("api.health._probe_neo4j"), + patch("api.health._probe_graph_db"), ): response = api_client.get(reverse("health-ready")) @@ -141,13 +158,13 @@ class TestReadinessEndpoint: # Exception detail is never echoed in the response, only logged. assert "output" not in pg_entry assert body["checks"]["valkey:responseTime"][0]["status"] == "pass" - assert body["checks"]["neo4j:responseTime"][0]["status"] == "pass" + assert body["checks"]["graphdb:responseTime"][0]["status"] == "pass" def test_returns_503_and_fail_when_valkey_is_down(self, api_client): with ( patch("api.health._probe_postgres"), patch("api.health._probe_valkey", side_effect=ConnectionError("timeout")), - patch("api.health._probe_neo4j"), + patch("api.health._probe_graph_db"), ): response = api_client.get(reverse("health-ready")) @@ -158,12 +175,12 @@ class TestReadinessEndpoint: assert vk_entry["status"] == "fail" assert "output" not in vk_entry - def test_returns_503_and_fail_when_neo4j_is_down(self, api_client): + def test_returns_503_and_fail_when_graph_db_is_down(self, api_client): with ( patch("api.health._probe_postgres"), patch("api.health._probe_valkey"), patch( - "api.health._probe_neo4j", + "api.health._probe_graph_db", side_effect=RuntimeError("ServiceUnavailable"), ), ): @@ -172,15 +189,15 @@ class TestReadinessEndpoint: assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE body = response.json() assert body["status"] == "fail" - neo_entry = body["checks"]["neo4j:responseTime"][0] - assert neo_entry["status"] == "fail" - assert "output" not in neo_entry + graph_db_entry = body["checks"]["graphdb:responseTime"][0] + assert graph_db_entry["status"] == "fail" + assert "output" not in graph_db_entry def test_reports_all_failures_simultaneously(self, api_client): with ( patch("api.health._probe_postgres", side_effect=RuntimeError("pg down")), patch("api.health._probe_valkey", side_effect=RuntimeError("vk down")), - patch("api.health._probe_neo4j", side_effect=RuntimeError("neo down")), + patch("api.health._probe_graph_db", side_effect=RuntimeError("neo down")), ): response = api_client.get(reverse("health-ready")) @@ -190,7 +207,7 @@ class TestReadinessEndpoint: for key in ( "postgres:responseTime", "valkey:responseTime", - "neo4j:responseTime", + "graphdb:responseTime", ): entry = body["checks"][key][0] assert entry["status"] == "fail" @@ -209,7 +226,7 @@ class TestReadinessEndpoint: with ( patch("api.health._probe_postgres", side_effect=RuntimeError(sensitive)), patch("api.health._probe_valkey"), - patch("api.health._probe_neo4j"), + patch("api.health._probe_graph_db"), ): response = api_client.get(reverse("health-ready")) @@ -229,7 +246,7 @@ class TestReadinessEndpoint: with ( patch("api.health._probe_postgres"), patch("api.health._probe_valkey"), - patch("api.health._probe_neo4j"), + patch("api.health._probe_graph_db"), ): api_client.credentials() response = api_client.get(reverse("health-ready")) @@ -244,7 +261,7 @@ class TestReadinessCache: with ( patch("api.health._probe_postgres") as pg, patch("api.health._probe_valkey") as vk, - patch("api.health._probe_neo4j") as neo, + patch("api.health._probe_graph_db") as neo, ): r1 = api_client.get(reverse("health-ready")) r2 = api_client.get(reverse("health-ready")) @@ -262,7 +279,7 @@ class TestReadinessCache: with ( patch("api.health._probe_postgres") as pg, patch("api.health._probe_valkey"), - patch("api.health._probe_neo4j"), + patch("api.health._probe_graph_db"), ): api_client.get(reverse("health-ready")) assert pg.call_count == 1 @@ -286,7 +303,7 @@ class TestReadinessCache: with ( patch("api.health._probe_postgres", side_effect=RuntimeError("down")) as pg, patch("api.health._probe_valkey"), - patch("api.health._probe_neo4j"), + patch("api.health._probe_graph_db"), ): r1 = api_client.get(reverse("health-ready")) r2 = api_client.get(reverse("health-ready")) @@ -320,7 +337,7 @@ class TestRateLimiting: with ( patch("api.health._probe_postgres"), patch("api.health._probe_valkey"), - patch("api.health._probe_neo4j"), + patch("api.health._probe_graph_db"), patch.object(ScopedRateThrottle, "parse_rate", return_value=(2, 60)), ): statuses = [ @@ -414,19 +431,42 @@ class TestProbeImplementations: with pytest.raises(RuntimeError, match="bug"): health._probe_valkey() - def test_neo4j_probe_calls_verify_connectivity(self): - with patch("api.attack_paths.database.get_driver") as mock_get_driver: - mock_get_driver.return_value.verify_connectivity.return_value = None - assert health._probe_neo4j() is None - mock_get_driver.return_value.verify_connectivity.assert_called_once_with() + def test_graph_db_probe_calls_verify_connectivity(self): + with patch("api.attack_paths.database.verify_connectivity") as mock_verify: + mock_verify.return_value = None + assert health._probe_graph_db() is None + mock_verify.assert_called_once_with() - def test_neo4j_probe_propagates_driver_errors(self): - with patch("api.attack_paths.database.get_driver") as mock_get_driver: - mock_get_driver.return_value.verify_connectivity.side_effect = RuntimeError( - "unreachable" - ) + def test_graph_db_probe_propagates_errors(self): + with patch( + "api.attack_paths.database.verify_connectivity", + side_effect=RuntimeError("unreachable"), + ): with pytest.raises(RuntimeError, match="unreachable"): - health._probe_neo4j() + health._probe_graph_db() + + def test_graph_db_probe_times_out_when_check_exceeds_budget(self): + # A sink whose connectivity check blocks past the probe budget must + # surface as a failure fast, not pin the request thread for the + # driver's full acquisition timeout. + import time as _time + + def _hang() -> None: + _time.sleep(2) + + with ( + patch("api.health.GRAPH_DB_PROBE_TIMEOUT_SECONDS", 0.2), + patch( + "api.attack_paths.database.verify_connectivity", + side_effect=_hang, + ), + ): + started = _time.perf_counter() + with pytest.raises(TimeoutError): + health._probe_graph_db() + elapsed = _time.perf_counter() - started + + assert elapsed < health.GRAPH_DB_PROBE_TIMEOUT_SECONDS + 1 class TestStatusAggregation: diff --git a/api/src/backend/api/tests/test_sink.py b/api/src/backend/api/tests/test_sink.py new file mode 100644 index 0000000000..4bb302d492 --- /dev/null +++ b/api/src/backend/api/tests/test_sink.py @@ -0,0 +1,629 @@ +"""Tests for the attack-paths sink factory and Neo4j sink. + +The sink module picks a backend per ``settings.ATTACK_PATHS_SINK_DATABASE``. +Neo4j is the default and preserves today's behavior; Neptune is opt-in and +builds dual writer/reader Bolt drivers. +""" + +import json +from importlib import import_module +from unittest.mock import MagicMock, patch + +import pytest + +# Prime patch-target resolution. `api.attack_paths.sink/__init__.py` doesn't +# eagerly import these submodules (they're loaded on demand inside the +# factory), so `mock.patch("api.attack_paths.sink..…")` would fail with +# AttributeError on first call. Importing here registers them as attributes +# of the package before any decorator runs. +import_module("api.attack_paths.sink.neo4j") +import_module("api.attack_paths.sink.neptune") + + +@pytest.fixture(autouse=True) +def reset_sink_state(): + """Reset the module-level backend singletons around each test. + + The cache lives in `api.attack_paths.sink.factory`, not on the package. + """ + from api.attack_paths.sink import factory + + original_backend = factory._backend + original_secondary = dict(factory._secondary_backends) + factory._backend = None + factory._secondary_backends.clear() + yield + factory._backend = original_backend + factory._secondary_backends.clear() + factory._secondary_backends.update(original_secondary) + + +class TestSinkFactory: + def test_default_resolves_to_neo4j(self, settings): + from api.attack_paths.sink import factory + + settings.ATTACK_PATHS_SINK_DATABASE = "neo4j" + assert factory._resolve_setting() == "neo4j" + + def test_neptune_resolves_correctly(self, settings): + from api.attack_paths.sink import factory + + settings.ATTACK_PATHS_SINK_DATABASE = "neptune" + assert factory._resolve_setting() == "neptune" + + def test_invalid_value_raises(self, settings): + from api.attack_paths.sink import factory + + settings.ATTACK_PATHS_SINK_DATABASE = "foo" + with pytest.raises(RuntimeError, match="ATTACK_PATHS_SINK_DATABASE"): + factory._resolve_setting() + + @patch("api.attack_paths.sink.neo4j.neo4j.GraphDatabase.driver") + def test_init_builds_neo4j_backend_by_default(self, mock_driver, settings): + from api.attack_paths import sink as sink_module + from api.attack_paths.sink.neo4j import Neo4jSink + + settings.ATTACK_PATHS_SINK_DATABASE = "neo4j" + settings.DATABASES = { + **settings.DATABASES, + "neo4j": { + "HOST": "localhost", + "PORT": "7687", + "USER": "neo4j", + "PASSWORD": "pw", + }, + } + mock_driver.return_value = MagicMock() + + backend = sink_module.init() + + assert isinstance(backend, Neo4jSink) + mock_driver.assert_called_once() + + @patch("api.attack_paths.sink.neptune.neptune_auth_provider") + @patch("api.attack_paths.sink.neptune.neo4j.GraphDatabase.driver") + def test_init_builds_neptune_backend( + self, mock_driver, mock_auth_provider, settings + ): + from api.attack_paths import sink as sink_module + from api.attack_paths.sink.neptune import NeptuneSink + + settings.ATTACK_PATHS_SINK_DATABASE = "neptune" + settings.DATABASES = { + **settings.DATABASES, + "neptune": { + "WRITER_ENDPOINT": "writer.example", + "READER_ENDPOINT": "reader.example", + "PORT": "8182", + "REGION": "eu-west-1", + }, + } + mock_driver.return_value = MagicMock() + mock_auth_provider.return_value = lambda: None + + backend = sink_module.init() + + assert isinstance(backend, NeptuneSink) + # Writer + reader endpoints both trigger driver construction + assert mock_driver.call_count == 2 + writer_uri = mock_driver.call_args_list[0][0][0] + reader_uri = mock_driver.call_args_list[1][0][0] + assert writer_uri == "bolt+s://writer.example:8182" + assert reader_uri == "bolt+s://reader.example:8182" + + @patch("api.attack_paths.sink.neptune.neptune_auth_provider") + @patch("api.attack_paths.sink.neptune.neo4j.GraphDatabase.driver") + def test_neptune_reader_falls_back_to_writer( + self, mock_driver, mock_auth_provider, settings + ): + from api.attack_paths import sink as sink_module + + settings.ATTACK_PATHS_SINK_DATABASE = "neptune" + settings.DATABASES = { + **settings.DATABASES, + "neptune": { + "WRITER_ENDPOINT": "writer.example", + "READER_ENDPOINT": "", + "PORT": "8182", + "REGION": "eu-west-1", + }, + } + mock_driver.return_value = MagicMock() + mock_auth_provider.return_value = lambda: None + + sink_module.init() + + # Only one driver call — reader aliases writer + assert mock_driver.call_count == 1 + + +class TestGetBackendForScan: + """``get_backend_for_scan`` routes by the row's recorded sink backend.""" + + @patch("api.attack_paths.sink.neo4j.neo4j.GraphDatabase.driver") + def test_legacy_scan_in_neo4j_process_uses_active_backend( + self, mock_driver, settings + ): + from api.attack_paths import sink as sink_module + + settings.ATTACK_PATHS_SINK_DATABASE = "neo4j" + settings.DATABASES = { + **settings.DATABASES, + "neo4j": { + "HOST": "localhost", + "PORT": "7687", + "USER": "neo4j", + "PASSWORD": "pw", + }, + } + mock_driver.return_value = MagicMock() + + scan = MagicMock(sink_backend="neo4j") + backend = sink_module.get_backend_for_scan(scan) + + assert backend is sink_module.get_backend() + + def test_neptune_scan_on_neo4j_process_uses_neptune_secondary(self, settings): + from api.attack_paths.sink import factory + + settings.ATTACK_PATHS_SINK_DATABASE = "neo4j" + active_neo4j = MagicMock(name="neo4j-active") + factory._backend = active_neo4j + + secondary_neptune = MagicMock(name="neptune-secondary") + with patch.object(factory, "_build_backend", return_value=secondary_neptune): + scan = MagicMock(sink_backend="neptune") + backend = factory.get_backend_for_scan(scan) + + assert backend is secondary_neptune + assert backend is not active_neo4j + + +def _session_ctx(session: MagicMock) -> MagicMock: + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=session) + ctx.__exit__ = MagicMock(return_value=False) + return ctx + + +def _count_result(key: str, count: int) -> MagicMock: + return MagicMock(single=MagicMock(return_value={key: count})) + + +def _directed_drop_results( + outgoing_rels: int, + incoming_rels: int, + nodes: int, +) -> list[MagicMock]: + return [ + _count_result("deleted_rels_count", outgoing_rels), + _count_result("deleted_rels_count", 0), + _count_result("deleted_rels_count", incoming_rels), + _count_result("deleted_rels_count", 0), + _count_result("deleted_nodes_count", nodes), + _count_result("deleted_nodes_count", 0), + ] + + +class TestNeo4jSinkSyncWrites: + def test_ensure_sync_indexes_runs_create_index_idempotent(self): + from api.attack_paths.sink.neo4j import Neo4jSink + + sink = Neo4jSink() + session = MagicMock() + session.run.return_value = MagicMock() + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + sink.ensure_sync_indexes("db-tenant-x") + + query = session.run.call_args.args[0] + assert "CREATE INDEX" in query + assert "IF NOT EXISTS" in query + assert "`_ProviderResource`" in query + assert "`_provider_element_id`" in query + + def test_write_nodes_skips_empty_batch(self): + from api.attack_paths.sink.neo4j import Neo4jSink + + sink = Neo4jSink() + with patch.object(sink, "get_session") as get_session: + sink.write_nodes("db-tenant-x", "`AWSUser`", []) + get_session.assert_not_called() + + def test_write_nodes_merges_on_provider_resource_label(self): + from api.attack_paths.sink.neo4j import Neo4jSink + + sink = Neo4jSink() + session = MagicMock() + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + sink.write_nodes( + "db-tenant-x", + "`AWSUser`:`_ProviderResource`", + [{"provider_element_id": "p:e", "props": {"k": "v"}}], + ) + + query, params = session.run.call_args.args + assert "MERGE (n:`_ProviderResource`" in query + assert "`_provider_element_id`: row.provider_element_id" in query + assert "SET n:`AWSUser`:`_ProviderResource`" in query + assert params == {"rows": [{"provider_element_id": "p:e", "props": {"k": "v"}}]} + + def test_write_relationships_scopes_endpoints_by_provider_label(self): + from api.attack_paths.sink.neo4j import Neo4jSink + + sink = Neo4jSink() + session = MagicMock() + provider_id = "00000000-0000-0000-0000-000000000abc" + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + sink.write_relationships( + "db-tenant-x", + "RESOURCE", + provider_id, + [ + { + "start_element_id": "s", + "end_element_id": "e", + "provider_element_id": "pe", + "props": {}, + } + ], + ) + + query = session.run.call_args.args[0] + assert ":`_Provider_00000000000000000000000000000abc`" in query + assert ":RESOURCE" in query.replace("`", "") + assert "MERGE (s)-[r:`RESOURCE`" in query + + +class TestNeptuneSinkSyncWrites: + def test_ensure_sync_indexes_is_noop(self): + from api.attack_paths.sink.neptune import NeptuneSink + + sink = NeptuneSink() + with patch.object(sink, "get_session") as get_session: + sink.ensure_sync_indexes("ignored") + get_session.assert_not_called() + + def test_write_nodes_merges_on_neptune_id_with_provider_resource_label(self): + from api.attack_paths.sink.neptune import NeptuneSink + + sink = NeptuneSink() + session = MagicMock() + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + sink.write_nodes( + "ignored", + "`AWSUser`", + [{"provider_element_id": "p:e", "props": {"k": "v"}}], + ) + + query = session.run.call_args.args[0] + # Neptune assigns a default `vertex` label to any unlabeled node, + # so the MERGE must pin a real label at creation time. + assert "MERGE (n:`_ProviderResource` {`~id`: row.provider_element_id})" in query + assert "SET n:`AWSUser`" in query + assert "SET n.`_provider_element_id` = row.provider_element_id" in query + + def test_write_relationships_matches_endpoints_by_id(self): + from api.attack_paths.sink.neptune import NeptuneSink + + sink = NeptuneSink() + session = MagicMock() + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + sink.write_relationships( + "ignored", + "RESOURCE", + "provider-1", + [ + { + "start_element_id": "s", + "end_element_id": "e", + "provider_element_id": "pe", + "props": {}, + } + ], + ) + + query = session.run.call_args.args[0] + assert "MATCH (s) WHERE id(s) = row.start_element_id" in query + assert "MATCH (e) WHERE id(e) = row.end_element_id" in query + assert "MERGE (s)-[r:`RESOURCE`" in query + + +class TestNeptuneSinkDropSubgraph: + def test_drop_subgraph_deletes_directed_rels_before_nodes_in_bounded_batches(self): + from api.attack_paths.sink.neptune import NeptuneSink + + sink = NeptuneSink() + session = MagicMock() + session.run.side_effect = _directed_drop_results( + outgoing_rels=50, + incoming_rels=30, + nodes=10, + ) + + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + deleted = sink.drop_subgraph("ignored", "provider-1") + + assert deleted == 10 + assert session.run.call_count == 6 + queries = [call.args[0] for call in session.run.call_args_list] + + assert ")-[r]->()" in queries[0] + assert ")<-[r]-()" in queries[2] + assert "DELETE n" in queries[4] + assert all("DETACH DELETE" not in query for query in queries) + assert all("DISTINCT r" not in query for query in queries) + + 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 + + +class TestNeo4jSinkDropSubgraph: + """Neo4j drop deletes relationships then nodes in batches (no ``DETACH DELETE``).""" + + def test_drop_subgraph_deletes_directed_rels_before_nodes_in_bounded_batches(self): + from api.attack_paths.sink.neo4j import Neo4jSink + + sink = Neo4jSink() + session = MagicMock() + session.run.side_effect = _directed_drop_results( + outgoing_rels=50, + incoming_rels=30, + nodes=10, + ) + + provider_id = "00000000-0000-0000-0000-000000000abc" + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + deleted = sink.drop_subgraph("db-tenant-x", provider_id) + + # Only phase-2 node counts contribute to the return value. + assert deleted == 10 + assert session.run.call_count == 6 + + 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) + assert all("DISTINCT r" not in query for query in queries) + + first_query = queries[0] + assert "DELETE r" in first_query + assert ")-[r]->()" in first_query + assert ":`_Provider_00000000000000000000000000000abc`" in first_query + + assert ")<-[r]-()" in queries[2] + assert "DELETE n" in queries[4] + + # 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_drop_subgraph_returns_zero_when_database_does_not_exist(self): + from api.attack_paths.database import GraphDatabaseQueryException + from api.attack_paths.sink.neo4j import DATABASE_NOT_FOUND_CODE, Neo4jSink + + sink = Neo4jSink() + session = MagicMock() + session.run.side_effect = GraphDatabaseQueryException( + message="db missing", code=DATABASE_NOT_FOUND_CODE + ) + + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + deleted = sink.drop_subgraph("db-tenant-missing", "provider-1") + + assert deleted == 0 + + +class TestSinkHasProviderData: + """``has_provider_data`` is the read-path probe used by API views.""" + + def test_neo4j_returns_true_when_provider_node_exists(self): + from api.attack_paths.sink.neo4j import Neo4jSink + + sink = Neo4jSink() + session = MagicMock() + session.run.return_value.single.return_value = MagicMock() + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + present = sink.has_provider_data( + "db-tenant-x", "00000000-0000-0000-0000-000000000abc" + ) + + assert present is True + query = session.run.call_args.args[0] + assert ":`_Provider_00000000000000000000000000000abc`" in query + + def test_neo4j_returns_false_when_database_does_not_exist(self): + from api.attack_paths.database import GraphDatabaseQueryException + from api.attack_paths.sink.neo4j import DATABASE_NOT_FOUND_CODE, Neo4jSink + + sink = Neo4jSink() + session = MagicMock() + session.run.side_effect = GraphDatabaseQueryException( + message="db missing", code=DATABASE_NOT_FOUND_CODE + ) + + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + present = sink.has_provider_data("db-tenant-missing", "provider-1") + + assert present is False + + def test_neptune_returns_true_when_provider_node_exists(self): + from api.attack_paths.sink.neptune import NeptuneSink + + sink = NeptuneSink() + session = MagicMock() + session.run.return_value.single.return_value = MagicMock() + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + present = sink.has_provider_data("ignored", "provider-1") + + assert present is True + + +class TestGetBackendForScanCutover: + """``get_backend_for_scan`` keeps old-sink scans queryable after cutover.""" + + def test_legacy_scan_on_neptune_process_uses_neo4j_secondary(self, settings): + from api.attack_paths.sink import factory + + settings.ATTACK_PATHS_SINK_DATABASE = "neptune" + active_neptune = MagicMock(name="neptune-active") + factory._backend = active_neptune + + secondary_neo4j = MagicMock(name="neo4j-secondary") + with patch.object(factory, "_build_backend", return_value=secondary_neo4j): + scan = MagicMock(sink_backend="neo4j") + backend = factory.get_backend_for_scan(scan) + + assert backend is secondary_neo4j + assert backend is not active_neptune + + +class TestSinkVerifyConnectivity: + """The readiness probe calls ``verify_connectivity`` through the shim. + + Neo4j checks its single driver; Neptune checks the reader (the API read + path), which on single-endpoint clusters aliases the writer. + """ + + @patch("api.attack_paths.sink.neo4j.neo4j.GraphDatabase.driver") + def test_neo4j_verifies_its_driver(self, mock_driver, settings): + from api.attack_paths.sink.neo4j import Neo4jSink + + settings.DATABASES = { + **settings.DATABASES, + "neo4j": { + "HOST": "localhost", + "PORT": "7687", + "USER": "neo4j", + "PASSWORD": "pw", + }, + } + driver = MagicMock() + mock_driver.return_value = driver + + sink = Neo4jSink() + sink.init() + driver.verify_connectivity.reset_mock() # ignore the eager init check + sink.verify_connectivity() + + driver.verify_connectivity.assert_called_once_with() + + @patch("api.attack_paths.sink.neptune.neptune_auth_provider") + @patch("api.attack_paths.sink.neptune.neo4j.GraphDatabase.driver") + def test_neptune_verifies_reader_not_writer( + self, mock_driver, mock_auth_provider, settings + ): + from api.attack_paths.sink.neptune import NeptuneSink + + settings.DATABASES = { + **settings.DATABASES, + "neptune": { + "WRITER_ENDPOINT": "writer.example", + "READER_ENDPOINT": "reader.example", + "PORT": "8182", + "REGION": "eu-west-1", + }, + } + writer, reader = MagicMock(name="writer"), MagicMock(name="reader") + mock_driver.side_effect = [writer, reader] + mock_auth_provider.return_value = lambda: None + + sink = NeptuneSink() + sink.init() + writer.verify_connectivity.reset_mock() + reader.verify_connectivity.reset_mock() + + sink.verify_connectivity() + + reader.verify_connectivity.assert_called_once_with() + writer.verify_connectivity.assert_not_called() + + +class TestSinkInitToleratesUnreachableSink: + """Init must not crash the process when the sink is down at boot. + + Same degradation model as Postgres: the driver is retained and + reconnects lazily; /health/ready surfaces the outage until it recovers. + """ + + @patch("api.attack_paths.sink.neo4j.neo4j.GraphDatabase.driver") + def test_neo4j_init_continues_when_verify_fails(self, mock_driver, settings): + from api.attack_paths.sink.neo4j import Neo4jSink + + settings.DATABASES = { + **settings.DATABASES, + "neo4j": { + "HOST": "localhost", + "PORT": "7687", + "USER": "neo4j", + "PASSWORD": "pw", + }, + } + driver = MagicMock() + driver.verify_connectivity.side_effect = RuntimeError("unreachable") + mock_driver.return_value = driver + + sink = Neo4jSink() + # Must not raise. + assert sink.init() is driver + assert sink._driver is driver + + @patch("api.attack_paths.sink.neptune.neptune_auth_provider") + @patch("api.attack_paths.sink.neptune.neo4j.GraphDatabase.driver") + def test_neptune_init_continues_when_verify_fails( + self, mock_driver, mock_auth_provider, settings + ): + from api.attack_paths.sink.neptune import NeptuneSink + + settings.DATABASES = { + **settings.DATABASES, + "neptune": { + "WRITER_ENDPOINT": "writer.example", + "READER_ENDPOINT": "reader.example", + "PORT": "8182", + "REGION": "eu-west-1", + }, + } + driver = MagicMock() + driver.verify_connectivity.side_effect = RuntimeError("unreachable") + mock_driver.return_value = driver + mock_auth_provider.return_value = lambda: None + + sink = NeptuneSink() + # Must not raise; both drivers retained. + sink.init() + assert sink._writer is not None + assert sink._reader is not None + + +class TestNeptuneAdminNoOps: + """Neptune is single-database; admin DDL has no work to do.""" + + @pytest.mark.parametrize("method", ["create_database", "drop_database"]) + def test_admin_ops_return_none_without_touching_a_session(self, method): + from api.attack_paths.sink.neptune import NeptuneSink + + sink = NeptuneSink() + with patch.object(sink, "get_session") as get_session: + assert getattr(sink, method)("ignored") is None + get_session.assert_not_called() + + +class TestNeptuneAuthToken: + """SigV4 signing for the Neptune Bolt endpoint.""" + + @patch("api.attack_paths.sink.neptune.SigV4Auth") + @patch("api.attack_paths.sink.neptune.BotoSession") + def test_host_header_includes_non_default_port(self, mock_boto, mock_sigv4): + # Neptune runs on 8182; the SigV4 canonical Host must keep the port or + # the signature is rejected. + from api.attack_paths.sink.neptune import _NeptuneAuthToken + + credentials = MagicMock() + credentials.get_frozen_credentials.return_value = MagicMock() + mock_boto.return_value.get_credentials.return_value = credentials + + token = _NeptuneAuthToken("eu-west-1", "https://writer.example:8182") + + auth_obj = json.loads(token.credentials) + assert auth_obj["Host"] == "writer.example:8182" diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 4e8c9336b6..392d859698 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -57,6 +57,7 @@ from api.models import ( UserRoleRelationship, ) from api.rls import Tenant +from api.uuid_utils import datetime_to_uuid7 from api.v1.serializers import TokenSerializer from api.v1.views import ComplianceOverviewViewSet, TenantFinishACSView from botocore.exceptions import ClientError, NoCredentialsError @@ -4754,6 +4755,64 @@ class TestAttackPathsScanViewSet: assert first_attributes["provider_type"] == provider.provider assert first_attributes["provider_uid"] == provider.uid + def test_attack_paths_scans_list_prefers_active_sink_scan_on_rollback( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + settings, + ): + settings.ATTACK_PATHS_SINK_DATABASE = "neo4j" + provider = providers_fixture[0] + + neo4j_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + state=StateChoices.COMPLETED, + graph_data_ready=True, + sink_backend="neo4j", + ) + neptune_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + state=StateChoices.COMPLETED, + graph_data_ready=True, + sink_backend="neptune", + ) + + response = authenticated_client.get(reverse("attack-paths-scans-list")) + + assert response.status_code == status.HTTP_200_OK + ids = {item["id"] for item in response.json()["data"]} + assert str(neo4j_scan.id) in ids + assert str(neptune_scan.id) not in ids + + def test_attack_paths_scans_list_falls_back_when_active_sink_has_no_scan( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + settings, + ): + settings.ATTACK_PATHS_SINK_DATABASE = "neptune" + provider = providers_fixture[0] + + legacy_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + state=StateChoices.COMPLETED, + graph_data_ready=True, + sink_backend="neo4j", + ) + + response = authenticated_client.get(reverse("attack-paths-scans-list")) + + assert response.status_code == status.HTTP_200_OK + ids = {item["id"] for item in response.json()["data"]} + assert str(legacy_scan.id) in ids + def test_attack_paths_scans_list_respects_provider_group_visibility( self, authenticated_client_no_permissions_rbac, @@ -4874,7 +4933,8 @@ class TestAttackPathsScanViewSet: ) assert response.status_code == status.HTTP_200_OK - mock_get_queries.assert_called_once_with(provider.provider) + # TODO: drop the is_migrated argument after Neptune cutover + mock_get_queries.assert_called_once_with(provider.provider, is_migrated=False) payload = response.json()["data"] assert len(payload) == 1 assert payload[0]["id"] == "aws-rds" @@ -4974,7 +5034,8 @@ class TestAttackPathsScanViewSet: ) assert response.status_code == status.HTTP_200_OK - mock_get_query.assert_called_once_with("aws-rds") + # TODO: drop the is_migrated argument after Neptune cutover + mock_get_query.assert_called_once_with("aws-rds", is_migrated=False) mock_get_db_name.assert_called_once_with(attack_paths_scan.provider.tenant_id) provider_id = str(attack_paths_scan.provider_id) mock_prepare.assert_called_once_with( @@ -4988,6 +5049,7 @@ class TestAttackPathsScanViewSet: query_definition, prepared_parameters, provider_id, + scan=attack_paths_scan, ) result = response.json()["data"] attributes = result["attributes"] @@ -5339,6 +5401,7 @@ class TestAttackPathsScanViewSet: "db-test", "MATCH (n) RETURN n", str(attack_paths_scan.provider_id), + scan=attack_paths_scan, ) attributes = response.json()["data"]["attributes"] assert len(attributes["nodes"]) == 1 @@ -5875,9 +5938,10 @@ class TestAttackPathsScanViewSet: ) assert response.status_code == status.HTTP_200_OK - mock_get_schema.assert_called_once_with( - "db-test", str(attack_paths_scan.provider_id) - ) + mock_get_schema.assert_called_once() + schema_args = mock_get_schema.call_args[0] + assert schema_args[:2] == ("db-test", str(attack_paths_scan.provider_id)) + assert schema_args[2].id == attack_paths_scan.id attributes = response.json()["data"]["attributes"] assert attributes["provider"] == "aws" assert attributes["cartography_version"] == "0.129.0" @@ -7155,6 +7219,26 @@ class TestFindingViewSet: assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json()["errors"][0]["code"] == "invalid" + def test_findings_updated_at_range_too_large_with_inserted_at_filter( + self, authenticated_client + ): + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[inserted_at]": TODAY, + "filter[updated_at.gte]": today_after_n_days( + -(settings.FINDINGS_MAX_DAYS_IN_RANGE + 1) + ), + "filter[updated_at.lte]": TODAY, + }, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["errors"][0]["code"] == "invalid" + assert response.json()["errors"][0]["source"]["pointer"] == ( + "/data/attributes/updated_at" + ) + def test_findings_list(self, authenticated_client, findings_fixture): response = authenticated_client.get( reverse("finding-list"), {"filter[inserted_at]": TODAY} @@ -7166,6 +7250,170 @@ class TestFindingViewSet: == findings_fixture[0].status ) + def test_findings_list_inserted_at_accepts_timestamp_precision_filters( + self, authenticated_client, scans_fixture + ): + scan, *_ = scans_fixture + + def create_finding(uid, inserted_at): + finding = Finding.objects.create( + id=datetime_to_uuid7(inserted_at), + tenant_id=scan.tenant_id, + uid=uid, + scan=scan, + status=Status.FAIL, + status_extended="timestamp precision status", + impact=Severity.medium, + severity=Severity.medium, + check_id="timestamp_precision_check", + check_metadata={ + "CheckId": "timestamp_precision_check", + "Description": "timestamp precision check", + "servicename": "ec2", + }, + first_seen_at=inserted_at, + ) + Finding.all_objects.filter(pk=finding.pk).update( + inserted_at=inserted_at, + updated_at=inserted_at, + ) + finding.refresh_from_db() + return finding + + create_finding( + "timestamp_precision_early", + datetime(2026, 1, 15, 10, 30, 0, 100000, tzinfo=UTC), + ) + late_finding = create_finding( + "timestamp_precision_late", + datetime(2026, 1, 15, 10, 30, 0, 200000, tzinfo=UTC), + ) + + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[inserted_at.gte]": "2026-01-15T10:30:00.150Z", + "filter[inserted_at.lte]": "2026-01-15T10:30:00.250Z", + }, + ) + + assert response.status_code == status.HTTP_200_OK + returned_uids = { + finding["attributes"]["uid"] for finding in response.json()["data"] + } + assert returned_uids == {late_finding.uid} + + response = authenticated_client.get( + reverse("finding-list"), + {"filter[inserted_at]": "2026-01-15T10:30:00.200Z"}, + ) + + assert response.status_code == status.HTTP_200_OK + returned_uids = { + finding["attributes"]["uid"] for finding in response.json()["data"] + } + assert returned_uids == {late_finding.uid} + + def test_findings_list_updated_at_accepts_timestamp_precision_filters( + self, authenticated_client, findings_fixture + ): + early_finding, late_finding, *_ = findings_fixture + early_updated_at = datetime(2026, 1, 15, 10, 30, 0, 100000, tzinfo=UTC) + late_updated_at = datetime(2026, 1, 15, 10, 30, 0, 200000, tzinfo=UTC) + Finding.all_objects.filter(pk=early_finding.pk).update( + updated_at=early_updated_at + ) + Finding.all_objects.filter(pk=late_finding.pk).update( + updated_at=late_updated_at + ) + + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[updated_at.gte]": "2026-01-15T10:30:00.150Z", + "filter[updated_at.lte]": "2026-01-15T10:30:00.250Z", + }, + ) + + assert response.status_code == status.HTTP_200_OK + returned_uids = { + finding["attributes"]["uid"] for finding in response.json()["data"] + } + assert returned_uids == {late_finding.uid} + + response = authenticated_client.get( + reverse("finding-list"), + {"filter[updated_at]": "2026-01-15T10:30:00.200Z"}, + ) + + assert response.status_code == status.HTTP_200_OK + returned_uids = { + finding["attributes"]["uid"] for finding in response.json()["data"] + } + assert returned_uids == {late_finding.uid} + + def test_findings_list_inserted_at_and_updated_at_filters_are_combined( + self, authenticated_client, scans_fixture + ): + scan, *_ = scans_fixture + + def create_finding(uid, inserted_at, updated_at): + finding = Finding.objects.create( + id=datetime_to_uuid7(inserted_at), + tenant_id=scan.tenant_id, + uid=uid, + scan=scan, + status=Status.FAIL, + status_extended="timestamp precision status", + impact=Severity.medium, + severity=Severity.medium, + check_id="timestamp_precision_check", + check_metadata={ + "CheckId": "timestamp_precision_check", + "Description": "timestamp precision check", + "servicename": "ec2", + }, + first_seen_at=inserted_at, + ) + Finding.all_objects.filter(pk=finding.pk).update( + inserted_at=inserted_at, + updated_at=updated_at, + ) + finding.refresh_from_db() + return finding + + matching_finding = create_finding( + "timestamp_precision_combined_match", + datetime(2026, 1, 15, 10, 30, 0, 200000, tzinfo=UTC), + datetime(2026, 1, 15, 11, 30, 0, 200000, tzinfo=UTC), + ) + create_finding( + "timestamp_precision_combined_inserted_only", + datetime(2026, 1, 15, 10, 30, 0, 200000, tzinfo=UTC), + datetime(2026, 1, 15, 12, 30, 0, 200000, tzinfo=UTC), + ) + create_finding( + "timestamp_precision_combined_updated_only", + datetime(2026, 1, 15, 9, 30, 0, 200000, tzinfo=UTC), + datetime(2026, 1, 15, 11, 30, 0, 200000, tzinfo=UTC), + ) + + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[inserted_at.gte]": "2026-01-15T10:30:00.150Z", + "filter[inserted_at.lte]": "2026-01-15T10:30:00.250Z", + "filter[updated_at.gte]": "2026-01-15T11:30:00.150Z", + "filter[updated_at.lte]": "2026-01-15T11:30:00.250Z", + }, + ) + + assert response.status_code == status.HTTP_200_OK + returned_uids = { + finding["attributes"]["uid"] for finding in response.json()["data"] + } + assert returned_uids == {matching_finding.uid} + def test_findings_list_resource_tags_no_n_plus_one( self, authenticated_client, findings_fixture ): @@ -7631,6 +7879,23 @@ class TestFindingViewSet: ] } + @pytest.mark.parametrize( + "filter_name", + ["inserted_at", "inserted_at.gte", "inserted_at.lte"], + ) + def test_findings_metadata_rejects_timestamp_precision_filters( + self, authenticated_client, filter_name + ): + response = authenticated_client.get( + reverse("finding-metadata"), + {f"filter[{filter_name}]": "2048-01-01T10:30:00Z"}, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + error = response.json()["errors"][0] + assert error["detail"] == "Enter a valid date." + assert error["code"] == "invalid" + def test_findings_metadata_backfill( self, authenticated_client, scans_fixture, findings_fixture ): diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 89c2f344a2..d5eab3f704 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -50,6 +50,7 @@ from api.filters import ( FindingGroupAggregatedComputedFilter, FindingGroupFilter, FindingGroupSummaryFilter, + FindingMetadataFilter, IntegrationFilter, IntegrationJiraFindingsFilter, InvitationFilter, @@ -1888,8 +1889,8 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet): 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." + "produce this artifact (currently 'dora_2022_2554', 'csa_ccm_4.0' " + "and 'cis_controls_8.1'); any other framework returns 404." ), parameters=[ OpenApiParameter( @@ -2876,13 +2877,22 @@ class AttackPathsScanViewSet(BaseRLSViewSet): def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) + active_sink_backend = django_settings.ATTACK_PATHS_SINK_DATABASE latest_per_provider = queryset.annotate( + active_sink_rank=Case( + When(sink_backend=active_sink_backend, then=Value(0)), + default=Value(1), + output_field=IntegerField(), + ), latest_scan_rank=Window( expression=RowNumber(), partition_by=[F("provider_id")], - order_by=[F("inserted_at").desc()], - ) + order_by=[ + F("active_sink_rank").asc(), + F("inserted_at").desc(), + ], + ), ).filter(latest_scan_rank=1) page = self.paginate_queryset(latest_per_provider) @@ -2909,7 +2919,11 @@ class AttackPathsScanViewSet(BaseRLSViewSet): ) def attack_paths_queries(self, request, pk=None): attack_paths_scan = self.get_object() - queries = get_queries_for_provider(attack_paths_scan.provider.provider) + # TODO: drop the is_migrated argument after Neptune cutover + queries = get_queries_for_provider( + attack_paths_scan.provider.provider, + is_migrated=attack_paths_scan.is_migrated, + ) if not queries: return Response( @@ -2942,7 +2956,11 @@ class AttackPathsScanViewSet(BaseRLSViewSet): serializer = AttackPathsQueryRunRequestSerializer(data=payload) serializer.is_valid(raise_exception=True) - query_definition = get_query_by_id(serializer.validated_data["id"]) + # TODO: drop the is_migrated argument after Neptune cutover + query_definition = get_query_by_id( + serializer.validated_data["id"], + is_migrated=attack_paths_scan.is_migrated, + ) if ( query_definition is None or query_definition.provider != attack_paths_scan.provider.provider @@ -2968,6 +2986,7 @@ class AttackPathsScanViewSet(BaseRLSViewSet): query_definition, parameters, provider_id, + scan=attack_paths_scan, ) query_duration = time.monotonic() - start @@ -3035,6 +3054,7 @@ class AttackPathsScanViewSet(BaseRLSViewSet): database_name, serializer.validated_data["query"], provider_id, + scan=attack_paths_scan, ) query_duration = time.monotonic() - start @@ -3091,7 +3111,7 @@ class AttackPathsScanViewSet(BaseRLSViewSet): provider_id = str(attack_paths_scan.provider_id) schema = attack_paths_views_helpers.get_cartography_schema( - database_name, provider_id + database_name, provider_id, attack_paths_scan ) if not schema: return Response( @@ -3814,6 +3834,8 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): def get_filterset_class(self): if self.action in ["latest", "metadata_latest"]: return LatestFindingFilter + if self.action == "metadata": + return FindingMetadataFilter return FindingFilter def get_queryset(self): diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 31a9537b5f..75d5e6112e 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -311,6 +311,11 @@ ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int( "ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880 ) # 48h +# Selects where the persistent attack-paths graph is stored. The scan +# temporary database is always Neo4j; only the sink is configurable. +# Valid values: "neo4j" (default, OSS and local dev), "neptune" (hosted). +ATTACK_PATHS_SINK_DATABASE = env.str("ATTACK_PATHS_SINK_DATABASE", default="neo4j") + # 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 diff --git a/api/src/backend/config/django/devel.py b/api/src/backend/config/django/devel.py index 6921790ca3..5b3871aa8b 100644 --- a/api/src/backend/config/django/devel.py +++ b/api/src/backend/config/django/devel.py @@ -50,6 +50,12 @@ DATABASES = { "USER": env.str("NEO4J_USER", "neo4j"), "PASSWORD": env.str("NEO4J_PASSWORD", "neo4j_password"), }, + "neptune": { + "WRITER_ENDPOINT": env.str("NEPTUNE_WRITER_ENDPOINT", ""), + "READER_ENDPOINT": env.str("NEPTUNE_READER_ENDPOINT", ""), + "PORT": env.str("NEPTUNE_PORT", "8182"), + "REGION": env.str("AWS_REGION", ""), + }, } DATABASES["default"] = DATABASES["prowler_user"] diff --git a/api/src/backend/config/django/production.py b/api/src/backend/config/django/production.py index cb651f6e76..79d8993b10 100644 --- a/api/src/backend/config/django/production.py +++ b/api/src/backend/config/django/production.py @@ -49,12 +49,19 @@ DATABASES = { "HOST": env("POSTGRES_REPLICA_HOST", default=default_db_host), "PORT": env("POSTGRES_REPLICA_PORT", default=default_db_port), }, + # TODO: drop after Neptune cutover just loosen defaults to `""` "neo4j": { "HOST": env.str("NEO4J_HOST"), "PORT": env.str("NEO4J_PORT"), "USER": env.str("NEO4J_USER"), "PASSWORD": env.str("NEO4J_PASSWORD"), }, + "neptune": { + "WRITER_ENDPOINT": env.str("NEPTUNE_WRITER_ENDPOINT", default=""), + "READER_ENDPOINT": env.str("NEPTUNE_READER_ENDPOINT", default=""), + "PORT": env.str("NEPTUNE_PORT", default="8182"), + "REGION": env.str("AWS_REGION", default=""), + }, } DATABASES["default"] = DATABASES["prowler_user"] diff --git a/api/src/backend/config/guniconf.py b/api/src/backend/config/guniconf.py index 9ede1d3164..8b17ad669d 100644 --- a/api/src/backend/config/guniconf.py +++ b/api/src/backend/config/guniconf.py @@ -83,12 +83,32 @@ def _warm_compliance_caches_in_background(): def post_fork(_server, worker): - """Warm compliance caches after each worker fork. + """Re-initialize attack-paths drivers and warm compliance caches per worker. - 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. + Neo4j / Neptune drivers spawn background IO threads that do not survive + ``fork()``. When the gunicorn master runs with ``preload_app=True``, the + child inherits driver objects whose pool references dead threads and + hangs on the first ``pool.acquire`` call until the watchdog kills the + worker. Re-initializing per worker guarantees each child owns its own + live threads. See GUNICORN_WORKER_TIMEOUTS_ANALYSIS.md for detail. + + Compliance caches are then warmed 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. """ + from api.attack_paths import database as graph_database + + try: + graph_database.close_driver() + except Exception: # pragma: no cover - best-effort cleanup + gunicorn_logger.debug( + "Failed to close inherited Neo4j driver in post_fork for worker pid=%s", + worker.pid, + exc_info=True, + ) + graph_database.init_driver() + gunicorn_logger.info(f"Attack-paths drivers initialized for worker {worker.pid}") + threading.Thread( target=_warm_compliance_caches_in_background, name="warm-compliance-caches", diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py index af58730e7d..b9154ddb51 100644 --- a/api/src/backend/conftest.py +++ b/api/src/backend/conftest.py @@ -1821,6 +1821,36 @@ def attack_paths_query_definition_factory(): return _create +@pytest.fixture +def sink_backend_stub(): + """Install a stub `SinkDatabase` into the sink factory for the test's duration. + + The sink factory caches a process-wide backend and lazily initializes it + against `settings.DATABASES["neo4j"]` / `["neptune"]`. Tests that don't + want to stand up a real Bolt driver can yield this fixture's mock and + configure its return values directly: + + sink_backend_stub.execute_read_query.return_value = some_graph + + Both the active backend and the secondary-backend cache are restored on + teardown so tests stay isolated. + """ + from api.attack_paths.sink import factory + from api.attack_paths.sink.base import SinkDatabase + + stub = MagicMock(spec=SinkDatabase) + previous_backend = factory._backend + previous_secondary = dict(factory._secondary_backends) + factory._backend = stub + factory._secondary_backends.clear() + try: + yield stub + finally: + factory._backend = previous_backend + factory._secondary_backends.clear() + factory._secondary_backends.update(previous_secondary) + + @pytest.fixture def attack_paths_graph_stub_classes(): """Provide lightweight graph element stubs for Attack Paths serialization tests.""" diff --git a/api/src/backend/tasks/jobs/attack_paths/aws.py b/api/src/backend/tasks/jobs/attack_paths/aws.py index 398c261ca4..15ecd86a19 100644 --- a/api/src/backend/tasks/jobs/attack_paths/aws.py +++ b/api/src/backend/tasks/jobs/attack_paths/aws.py @@ -6,6 +6,7 @@ from typing import Any import aioboto3 import boto3 +import botocore import neo4j from api.models import ( AttackPathsScan as ProwlerAPIAttackPathsScan, @@ -73,13 +74,28 @@ def start_aws_ingestion( # Adding an extra field common_job_parameters["AWS_ID"] = prowler_api_provider.uid - cartography_aws._autodiscover_accounts( - neo4j_session, - boto3_session, - prowler_api_provider.uid, - cartography_config.update_tag, - common_job_parameters, - ) + # AWS Organizations account autodiscovery. Inlined from Cartography's removed + # `_autodiscover_accounts` (deleted in `0.137.0`), as `load_aws_accounts` is still public. + try: + org_client = boto3_session.client("organizations") + paginator = org_client.get_paginator("list_accounts") + discovered = [] + for page in paginator.paginate(): + discovered.extend(page["Accounts"]) + active_accounts = { + a["Name"]: a["Id"] for a in discovered if a["Status"] == "ACTIVE" + } + cartography_aws.organizations.load_aws_accounts( + neo4j_session, + active_accounts, + cartography_config.update_tag, + common_job_parameters, + ) + except botocore.exceptions.ClientError: + logger.warning( + f"Account {prowler_api_provider.uid} lacks permissions for AWS " + "Organizations autodiscovery." + ) db_utils.update_attack_paths_scan_progress(attack_paths_scan, 4) failed_syncs = sync_aws_account( @@ -277,7 +293,7 @@ def sync_aws_account( sync_args: dict[str, Any], attack_paths_scan: ProwlerAPIAttackPathsScan, ) -> dict[str, str]: - current_progress = 4 # `cartography_aws._autodiscover_accounts` + current_progress = 4 # AWS Organizations account autodiscovery max_progress = ( 87 # `cartography_aws.RESOURCE_FUNCTIONS["permission_relationships"]` - 1 ) diff --git a/api/src/backend/tasks/jobs/attack_paths/cleanup.py b/api/src/backend/tasks/jobs/attack_paths/cleanup.py index 9ac2733faf..83192f18d0 100644 --- a/api/src/backend/tasks/jobs/attack_paths/cleanup.py +++ b/api/src/backend/tasks/jobs/attack_paths/cleanup.py @@ -8,7 +8,7 @@ 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, + mark_scan_finished, recover_graph_data_ready, ) from tasks.jobs.orphan_recovery import is_worker_alive as _is_worker_alive @@ -87,7 +87,7 @@ def _cleanup_stale_executing_scans(cutoff: datetime) -> list[str]: else: reason = "Worker dead — cleaned up by periodic task" else: - # No worker recorded — time-based heuristic only + # No worker recorded, time-based heuristic only if scan.started_at and scan.started_at >= cutoff: continue reason = ( @@ -160,7 +160,7 @@ def _cleanup_scan(scan, task_result, reason: str) -> bool: """ scan_id_str = str(scan.id) - # 1. Drop temp Neo4j database + # Drop temp Neo4j database tmp_db_name = graph_database.get_database_name(scan.id, temporary=True) try: graph_database.drop_database(tmp_db_name) @@ -225,6 +225,6 @@ def _finalize_failed_scan(scan, expected_state: str, reason: str): logger.info(f"Scan {scan_id_str} is now {fresh_scan.state}, skipping") return None - _mark_scan_finished(fresh_scan, StateChoices.FAILED, {"global_error": reason}) + mark_scan_finished(fresh_scan, StateChoices.FAILED, {"global_error": reason}) return fresh_scan diff --git a/api/src/backend/tasks/jobs/attack_paths/config.py b/api/src/backend/tasks/jobs/attack_paths/config.py index 78ca7eb038..d8ed63a8fc 100644 --- a/api/src/backend/tasks/jobs/attack_paths/config.py +++ b/api/src/backend/tasks/jobs/attack_paths/config.py @@ -1,9 +1,14 @@ from collections.abc import Callable -from dataclasses import dataclass from uuid import UUID from config.env import env -from tasks.jobs.attack_paths import aws +from tasks.jobs.attack_paths import provider_config as _provider_config + +# Re-export provider config objects so existing imports keep working. +AWS_CONFIG = _provider_config.AWS_CONFIG +NormalizedList = _provider_config.NormalizedList +PROVIDER_CONFIGS = _provider_config.PROVIDER_CONFIGS +ProviderConfig = _provider_config.ProviderConfig # Batch size for Neo4j write operations (resource labeling, cleanup) BATCH_SIZE = env.int("ATTACK_PATHS_BATCH_SIZE", 1000) @@ -21,42 +26,12 @@ PROWLER_FINDING_LABEL = "ProwlerFinding" PROVIDER_RESOURCE_LABEL = "_ProviderResource" # Dynamic isolation labels that contain entity UUIDs and are added to every synced node during sync -# Format: _Tenant_{uuid_no_hyphens}, _Provider_{uuid_no_hyphens} +# Format: `_Tenant_{uuid_no_hyphens}`, `_Provider_{uuid_no_hyphens}` TENANT_LABEL_PREFIX = "_Tenant_" PROVIDER_LABEL_PREFIX = "_Provider_" DYNAMIC_ISOLATION_PREFIXES = [TENANT_LABEL_PREFIX, PROVIDER_LABEL_PREFIX] -@dataclass(frozen=True) -class ProviderConfig: - """Configuration for a cloud provider's Attack Paths integration.""" - - name: str - root_node_label: str # e.g., "AWSAccount" - uid_field: str # e.g., "arn" - # Label for resources connected to the account node, enabling indexed finding lookups. - resource_label: str # e.g., "_AWSResource" - ingestion_function: Callable - # Maps a Postgres resource UID (e.g. full ARN) to the short-id form Cartography stores on some node types (e.g. `i-xxx` for EC2Instance). - short_uid_extractor: Callable[[str], str] - - -# Provider Configurations -# ----------------------- - -AWS_CONFIG = ProviderConfig( - name="aws", - root_node_label="AWSAccount", - uid_field="arn", - resource_label="_AWSResource", - ingestion_function=aws.start_aws_ingestion, - short_uid_extractor=aws.extract_short_uid, -) - -PROVIDER_CONFIGS: dict[str, ProviderConfig] = { - "aws": AWS_CONFIG, -} - # Labels added by Prowler that should be filtered from API responses # Derived from provider configs + common internal labels INTERNAL_LABELS: list[str] = [ @@ -87,7 +62,6 @@ INTERNAL_PROPERTIES: list[str] = [ # Provider Config Accessors -# ------------------------- def is_provider_available(provider_type: str) -> bool: @@ -135,7 +109,6 @@ def get_short_uid_extractor(provider_type: str) -> Callable[[str], str]: # Dynamic Isolation Label Helpers -# -------------------------------- def _normalize_uuid(value: str | UUID) -> str: diff --git a/api/src/backend/tasks/jobs/attack_paths/db_utils.py b/api/src/backend/tasks/jobs/attack_paths/db_utils.py index f1bb4edef4..c444a62602 100644 --- a/api/src/backend/tasks/jobs/attack_paths/db_utils.py +++ b/api/src/backend/tasks/jobs/attack_paths/db_utils.py @@ -8,6 +8,8 @@ from api.models import Provider as ProwlerAPIProvider from api.models import StateChoices from cartography.config import Config as CartographyConfig from celery.utils.log import get_task_logger +from django.conf import settings +from django.db.models import Case, IntegerField, Value, When from tasks.jobs.attack_paths.config import is_provider_available logger = get_task_logger(__name__) @@ -29,13 +31,33 @@ def create_attack_paths_scan( return None with rls_transaction(tenant_id): - # Inherit graph_data_ready from the previous scan for this provider, - # so queries remain available while the new scan runs. - previous_data_ready = ProwlerAPIAttackPathsScan.objects.filter( - tenant_id=tenant_id, - provider_id=provider_id, - graph_data_ready=True, - ).exists() + # Inherit metadata from the previous ready scan for this provider so + # queries remain available while the new scan runs. The new row only + # flips to the target sink after its own graph sync succeeds. + active_sink_backend = settings.ATTACK_PATHS_SINK_DATABASE + previous_ready = ( + ProwlerAPIAttackPathsScan.objects.filter( + tenant_id=tenant_id, + provider_id=provider_id, + graph_data_ready=True, + ) + .annotate( + active_sink_rank=Case( + When(sink_backend=active_sink_backend, then=Value(0)), + default=Value(1), + output_field=IntegerField(), + ) + ) + .order_by("active_sink_rank", "-inserted_at") + .first() + ) + previous_data_ready = previous_ready is not None + inherited_is_migrated = previous_ready.is_migrated if previous_ready else False + inherited_sink_backend = ( + previous_ready.sink_backend + if previous_ready + else ProwlerAPIAttackPathsScan.SinkBackendChoices.NEO4J + ) attack_paths_scan = ProwlerAPIAttackPathsScan.objects.create( tenant_id=tenant_id, @@ -44,6 +66,8 @@ def create_attack_paths_scan( state=StateChoices.SCHEDULED, started_at=datetime.now(tz=UTC), graph_data_ready=previous_data_ready, + is_migrated=inherited_is_migrated, + sink_backend=inherited_sink_backend, ) attack_paths_scan.save() @@ -114,7 +138,7 @@ def starting_attack_paths_scan( return True -def _mark_scan_finished( +def mark_scan_finished( attack_paths_scan: ProwlerAPIAttackPathsScan, state: StateChoices, ingestion_exceptions: dict[str, Any], @@ -148,7 +172,7 @@ def finish_attack_paths_scan( ingestion_exceptions: dict[str, Any], ) -> None: with rls_transaction(attack_paths_scan.tenant_id): - _mark_scan_finished(attack_paths_scan, state, ingestion_exceptions) + mark_scan_finished(attack_paths_scan, state, ingestion_exceptions) def update_attack_paths_scan_progress( @@ -169,19 +193,45 @@ def set_graph_data_ready( attack_paths_scan.save(update_fields=["graph_data_ready"]) +def set_scan_migrated( + attack_paths_scan: ProwlerAPIAttackPathsScan, + migrated: bool, + sink_backend: str | None = None, +) -> None: + """Mark the scan as written with the current (migrated) schema. + + Called after a successful sync so the read catalog and sink backend only + switch once the new graph is actually live. + + # TODO: drop after Neptune cutover + """ + with rls_transaction(attack_paths_scan.tenant_id): + attack_paths_scan.is_migrated = migrated + update_fields = ["is_migrated"] + if sink_backend is not None: + attack_paths_scan.sink_backend = sink_backend + update_fields.append("sink_backend") + attack_paths_scan.save(update_fields=update_fields) + + def set_provider_graph_data_ready( attack_paths_scan: ProwlerAPIAttackPathsScan, ready: bool, + sink_backend: str | None = None, ) -> None: """ - Set `graph_data_ready` for ALL scans of the same provider. + Set `graph_data_ready` for scans of the same provider in one sink. - Used before drop/sync so that older scan IDs cannot bypass the query gate while the graph is being replaced. + Used before drop/sync so that older scan IDs in the target sink cannot + bypass the query gate while that sink's graph is being replaced. Scans + preserved in another sink stay queryable for rollback. """ + target_sink_backend = sink_backend or attack_paths_scan.sink_backend with rls_transaction(attack_paths_scan.tenant_id): ProwlerAPIAttackPathsScan.objects.filter( tenant_id=attack_paths_scan.tenant_id, provider_id=attack_paths_scan.provider_id, + sink_backend=target_sink_backend, ).update(graph_data_ready=ready) attack_paths_scan.refresh_from_db(fields=["graph_data_ready"]) @@ -202,10 +252,15 @@ def recover_graph_data_ready( next successful scan) is a worse outcome for the user. """ try: + from api.attack_paths import sink as sink_module + tenant_db = graph_database.get_database_name(attack_paths_scan.tenant_id) - if graph_database.has_provider_data( - tenant_db, str(attack_paths_scan.provider_id) - ): + # TODO: drop after Neptune cutover + # Check the backend that actually holds this scan's data, not the + # currently configured sink, a stale `EXECUTING` scan from before a + # backend switch must still be recoverable + backend = sink_module.get_backend_for_scan(attack_paths_scan) + if backend.has_provider_data(tenant_db, str(attack_paths_scan.provider_id)): set_provider_graph_data_ready(attack_paths_scan, True) logger.info( f"Recovered `graph_data_ready` for provider {attack_paths_scan.provider_id}" @@ -247,6 +302,6 @@ def fail_attack_paths_scan( return if fresh.state in (StateChoices.COMPLETED, StateChoices.FAILED): return - _mark_scan_finished(fresh, StateChoices.FAILED, {"global_error": error}) + mark_scan_finished(fresh, StateChoices.FAILED, {"global_error": error}) recover_graph_data_ready(fresh) diff --git a/api/src/backend/tasks/jobs/attack_paths/findings.py b/api/src/backend/tasks/jobs/attack_paths/findings.py index a5bc4c1ad5..6cc7ddb2e0 100644 --- a/api/src/backend/tasks/jobs/attack_paths/findings.py +++ b/api/src/backend/tasks/jobs/attack_paths/findings.py @@ -82,7 +82,6 @@ def _to_neo4j_dict( # Public API -# ---------- def analysis( @@ -196,7 +195,6 @@ def load_findings( # Findings Streaming (Generator-based) -# ------------------------------------- def stream_findings_with_resources( @@ -275,7 +273,6 @@ def _fetch_findings_batch( # Batch Enrichment -# ----------------- def _enrich_batch_with_resources( diff --git a/api/src/backend/tasks/jobs/attack_paths/indexes.py b/api/src/backend/tasks/jobs/attack_paths/indexes.py index c2b56197d4..50e8a12bcd 100644 --- a/api/src/backend/tasks/jobs/attack_paths/indexes.py +++ b/api/src/backend/tasks/jobs/attack_paths/indexes.py @@ -1,5 +1,6 @@ import neo4j from cartography.client.core.tx import run_write_query +from cartography.intel import create_indexes as cartography_create_indexes from celery.utils.log import get_task_logger from tasks.jobs.attack_paths.config import ( INTERNET_NODE_LABEL, @@ -30,14 +31,34 @@ SYNC_INDEX_STATEMENTS = [ def create_findings_indexes(neo4j_session: neo4j.Session) -> None: - """Create indexes for Prowler findings and resource lookups.""" + """Create indexes for Prowler findings and resource lookups. + + Runs `CREATE INDEX`, so the caller must only invoke this against a Neo4j + session (the temp ingest DB or a Neo4j sink). Neptune auto-manages indexes + and rejects `CREATE INDEX`, so callers skip it for the Neptune sink. + """ logger.info("Creating indexes for Prowler Findings node types") for statement in FINDINGS_INDEX_STATEMENTS: run_write_query(neo4j_session, statement) +def create_cartography_indexes(neo4j_session: neo4j.Session, config) -> None: + """Create Cartography's standard indexes for the session's database. + + Runs `CREATE INDEX`, so the caller must only invoke this against a Neo4j + session (the temp ingest DB or a Neo4j sink). Neptune auto-manages indexes + and rejects `CREATE INDEX`, so callers skip it for the Neptune sink. + """ + cartography_create_indexes.run(neo4j_session, config) + + def create_sync_indexes(neo4j_session: neo4j.Session) -> None: - """Create indexes for provider resource sync operations.""" + """Create indexes for provider resource sync operations. + + Runs `CREATE INDEX`, so the caller must only invoke this against a Neo4j + session (the temp ingest DB or a Neo4j sink). Neptune auto-manages indexes + and rejects `CREATE INDEX`, so callers skip it for the Neptune sink. + """ logger.info("Ensuring ProviderResource indexes exist") for statement in SYNC_INDEX_STATEMENTS: neo4j_session.run(statement) diff --git a/api/src/backend/tasks/jobs/attack_paths/provider_config.py b/api/src/backend/tasks/jobs/attack_paths/provider_config.py new file mode 100644 index 0000000000..7d834e6aff --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/provider_config.py @@ -0,0 +1,431 @@ +""" +Provider-level Attack Paths configuration. + +Each `ProviderConfig` carries the cloud provider's ingestion entry point and +the catalog of list-typed node properties (`normalized_lists`). The sync +layer reads this catalog and materialises each list element as a child node +connected to the parent by a typed edge, so queries traverse the graph +instead of working on serialised list values. Both Neo4j and Neptune sinks +write the same shape and queries are portable across them. +""" + +from collections.abc import Callable +from dataclasses import dataclass, field + +from tasks.jobs.attack_paths import aws + + +@dataclass(frozen=True) +class NormalizedList: + """Catalog entry for a list-typed node property. + + Describes how the sync layer materialises a parent node's list-typed + property as a set of child item nodes connected by a typed edge. + + Conventions (mechanical, do not invent): + - `child_label`: `Item` + e.g. AWSPolicyStatement.resource -> AWSPolicyStatementResourceItem + - `rel_type`: `HAS_` + e.g. resource -> HAS_RESOURCE + - child node property: + * `field_map = []` (scalar list, ~95% case) -> child stores `value: str` + * `field_map = [(src_key, child_field), ...]` (list of dicts, rare) + -> child stores those fields + """ + + source_label: str + source_property: str + child_label: str + rel_type: str + field_map: list[tuple[str, str]] = field(default_factory=list) + + def __post_init__(self) -> None: + if self.field_map: + child_fields = [dst for _, dst in self.field_map] + if "value" in child_fields: + raise ValueError( + f"NormalizedList {self.source_label}.{self.source_property}: " + "`value` is reserved for scalar mode; do not map a source key to it" + ) + src_keys = [src for src, _ in self.field_map] + if len(set(src_keys)) != len(src_keys): + raise ValueError( + f"NormalizedList {self.source_label}.{self.source_property}: " + "duplicate source key in field_map" + ) + if len(set(child_fields)) != len(child_fields): + raise ValueError( + f"NormalizedList {self.source_label}.{self.source_property}: " + "duplicate child field in field_map" + ) + + +@dataclass(frozen=True) +class ProviderConfig: + """Configuration for a cloud provider's Attack Paths integration.""" + + name: str + root_node_label: str # e.g., "AWSAccount" + uid_field: str # e.g., "arn" + # Label for resources connected to the account node, enabling indexed finding lookups + resource_label: str # e.g., "_AWSResource" + ingestion_function: Callable + # Maps a Postgres resource UID (e.g. full ARN) to the short-id form Cartography stores on some node types (e.g. `i-xxx` for EC2Instance) + short_uid_extractor: Callable[[str], str] + # List-typed properties to materialise as child nodes + edges at sync time. + # Mandatory (may be []). Without an entry here, a list-typed property falls + # back to comma-string flatten and emits a one-time warning. + normalized_lists: list[NormalizedList] + + +# AWS list-typed property catalog. +# One entry per Cartography node property whose runtime value is a list. The +# sync layer materialises each element as a `` node and links it +# to the parent with a `` edge; see the `NormalizedList` docstring +# above for the naming conventions. +AWS_NORMALIZED_LISTS: list[NormalizedList] = [ + # AWSPolicyStatement - the hot path driving the 53-query perf fix. + NormalizedList( + "AWSPolicyStatement", "action", "AWSPolicyStatementActionItem", "HAS_ACTION" + ), + NormalizedList( + "AWSPolicyStatement", + "notaction", + "AWSPolicyStatementNotactionItem", + "HAS_NOTACTION", + ), + NormalizedList( + "AWSPolicyStatement", + "resource", + "AWSPolicyStatementResourceItem", + "HAS_RESOURCE", + ), + NormalizedList( + "AWSPolicyStatement", + "notresource", + "AWSPolicyStatementNotresourceItem", + "HAS_NOTRESOURCE", + ), + # S3PolicyStatement - same shape as IAM policies; AWS allows list or string. + NormalizedList( + "S3PolicyStatement", "action", "S3PolicyStatementActionItem", "HAS_ACTION" + ), + NormalizedList( + "S3PolicyStatement", "resource", "S3PolicyStatementResourceItem", "HAS_RESOURCE" + ), + # IAM / Cognito / KMS / Secrets + NormalizedList( + "CognitoIdentityPool", "roles", "CognitoIdentityPoolRolesItem", "HAS_ROLES" + ), + NormalizedList( + "KMSKey", + "encryption_algorithms", + "KMSKeyEncryptionAlgorithmsItem", + "HAS_ENCRYPTION_ALGORITHMS", + ), + NormalizedList( + "KMSKey", + "signing_algorithms", + "KMSKeySigningAlgorithmsItem", + "HAS_SIGNING_ALGORITHMS", + ), + NormalizedList( + "KMSKey", + "anonymous_actions", + "KMSKeyAnonymousActionsItem", + "HAS_ANONYMOUS_ACTIONS", + ), + NormalizedList( + "KMSGrant", "operations", "KMSGrantOperationsItem", "HAS_OPERATIONS" + ), + NormalizedList( + "SecretsManagerSecretVersion", + "version_stages", + "SecretsManagerSecretVersionVersionStagesItem", + "HAS_VERSION_STAGES", + ), + NormalizedList( + "SecretsManagerSecretVersion", + "kms_key_ids", + "SecretsManagerSecretVersionKmsKeyIdsItem", + "HAS_KMS_KEY_IDS", + ), + NormalizedList( + "SecretsManagerSecretVersion", + "tags", + "SecretsManagerSecretVersionTagsItem", + "HAS_TAGS", + field_map=[("Key", "key"), ("Value", "value_")], + # `value` is reserved for scalar mode; map `Value` to `value_` to keep dict shape. + ), + # Lambda / Compute + NormalizedList( + "AWSLambda", "architectures", "AWSLambdaArchitecturesItem", "HAS_ARCHITECTURES" + ), + NormalizedList( + "AWSLambda", + "anonymous_actions", + "AWSLambdaAnonymousActionsItem", + "HAS_ANONYMOUS_ACTIONS", + ), + NormalizedList( + "CodeBuildProject", + "environment_variables", + "CodeBuildProjectEnvironmentVariablesItem", + "HAS_ENVIRONMENT_VARIABLES", + ), + # ECS family + NormalizedList( + "ECSCluster", + "capacity_providers", + "ECSClusterCapacityProvidersItem", + "HAS_CAPACITY_PROVIDERS", + ), + NormalizedList( + "ECSTaskDefinition", + "compatibilities", + "ECSTaskDefinitionCompatibilitiesItem", + "HAS_COMPATIBILITIES", + ), + NormalizedList( + "ECSTaskDefinition", + "requires_compatibilities", + "ECSTaskDefinitionRequiresCompatibilitiesItem", + "HAS_REQUIRES_COMPATIBILITIES", + ), + NormalizedList( + "ECSContainerDefinition", + "links", + "ECSContainerDefinitionLinksItem", + "HAS_LINKS", + ), + NormalizedList( + "ECSContainerDefinition", + "entry_point", + "ECSContainerDefinitionEntryPointItem", + "HAS_ENTRY_POINT", + ), + NormalizedList( + "ECSContainerDefinition", + "command", + "ECSContainerDefinitionCommandItem", + "HAS_COMMAND", + ), + NormalizedList( + "ECSContainerDefinition", + "dns_servers", + "ECSContainerDefinitionDnsServersItem", + "HAS_DNS_SERVERS", + ), + NormalizedList( + "ECSContainerDefinition", + "dns_search_domains", + "ECSContainerDefinitionDnsSearchDomainsItem", + "HAS_DNS_SEARCH_DOMAINS", + ), + NormalizedList( + "ECSContainerDefinition", + "docker_security_options", + "ECSContainerDefinitionDockerSecurityOptionsItem", + "HAS_DOCKER_SECURITY_OPTIONS", + ), + NormalizedList("ECSContainer", "gpu_ids", "ECSContainerGpuIdsItem", "HAS_GPU_IDS"), + # ECR + NormalizedList( + "ECRImage", "layer_diff_ids", "ECRImageLayerDiffIdsItem", "HAS_LAYER_DIFF_IDS" + ), + NormalizedList( + "ECRImage", + "child_image_digests", + "ECRImageChildImageDigestsItem", + "HAS_CHILD_IMAGE_DIGESTS", + ), + # EC2 / Networking + NormalizedList( + "EC2Instance", + "exposed_internet_type", + "EC2InstanceExposedInternetTypeItem", + "HAS_EXPOSED_INTERNET_TYPE", + ), + NormalizedList( + "AutoScalingGroup", + "exposed_internet_type", + "AutoScalingGroupExposedInternetTypeItem", + "HAS_EXPOSED_INTERNET_TYPE", + ), + NormalizedList( + "LaunchConfiguration", + "security_groups", + "LaunchConfigurationSecurityGroupsItem", + "HAS_SECURITY_GROUPS", + ), + NormalizedList( + "LaunchTemplateVersion", + "security_group_ids", + "LaunchTemplateVersionSecurityGroupIdsItem", + "HAS_SECURITY_GROUP_IDS", + ), + NormalizedList( + "LaunchTemplateVersion", + "security_groups", + "LaunchTemplateVersionSecurityGroupsItem", + "HAS_SECURITY_GROUPS", + ), + NormalizedList( + "AWSVpcEndpoint", + "route_table_ids", + "AWSVpcEndpointRouteTableIdsItem", + "HAS_ROUTE_TABLE_IDS", + ), + NormalizedList( + "AWSVpcEndpoint", + "network_interface_ids", + "AWSVpcEndpointNetworkInterfaceIdsItem", + "HAS_NETWORK_INTERFACE_IDS", + ), + NormalizedList( + "AWSVpcEndpoint", + "subnet_ids", + "AWSVpcEndpointSubnetIdsItem", + "HAS_SUBNET_IDS", + ), + NormalizedList( + "ELBListener", "policy_names", "ELBListenerPolicyNamesItem", "HAS_POLICY_NAMES" + ), + # CloudFront / Route53 / CloudWatch / CloudTrail + NormalizedList( + "CloudFrontDistribution", + "aliases", + "CloudFrontDistributionAliasesItem", + "HAS_ALIASES", + ), + NormalizedList( + "CloudFrontDistribution", + "geo_restriction_locations", + "CloudFrontDistributionGeoRestrictionLocationsItem", + "HAS_GEO_RESTRICTION_LOCATIONS", + ), + NormalizedList( + "CloudWatchLogGroup", + "inherited_properties", + "CloudWatchLogGroupInheritedPropertiesItem", + "HAS_INHERITED_PROPERTIES", + ), + # RDS / Storage + NormalizedList( + "RDSCluster", + "availability_zones", + "RDSClusterAvailabilityZonesItem", + "HAS_AVAILABILITY_ZONES", + ), + NormalizedList( + "RDSEventSubscription", + "event_categories", + "RDSEventSubscriptionEventCategoriesItem", + "HAS_EVENT_CATEGORIES", + ), + NormalizedList( + "RDSEventSubscription", + "source_ids", + "RDSEventSubscriptionSourceIdsItem", + "HAS_SOURCE_IDS", + ), + NormalizedList( + "S3Bucket", + "anonymous_actions", + "S3BucketAnonymousActionsItem", + "HAS_ANONYMOUS_ACTIONS", + ), + # Inspector / Config / SSM / ACM / APIGateway / Glue / SageMaker / Bedrock + NormalizedList( + "AWSInspectorFinding", + "referenceurls", + "AWSInspectorFindingReferenceurlsItem", + "HAS_REFERENCEURLS", + ), + NormalizedList( + "AWSInspectorFinding", + "relatedvulnerabilities", + "AWSInspectorFindingRelatedvulnerabilitiesItem", + "HAS_RELATEDVULNERABILITIES", + ), + NormalizedList( + "AWSInspectorFinding", + "vulnerablepackageids", + "AWSInspectorFindingVulnerablepackageidsItem", + "HAS_VULNERABLEPACKAGEIDS", + ), + NormalizedList( + "AWSConfigurationRecorder", + "recording_group_resource_types", + "AWSConfigurationRecorderRecordingGroupResourceTypesItem", + "HAS_RECORDING_GROUP_RESOURCE_TYPES", + ), + NormalizedList( + "AWSConfigRule", + "scope_compliance_resource_types", + "AWSConfigRuleScopeComplianceResourceTypesItem", + "HAS_SCOPE_COMPLIANCE_RESOURCE_TYPES", + ), + NormalizedList( + "AWSConfigRule", + "source_details", + "AWSConfigRuleSourceDetailsItem", + "HAS_SOURCE_DETAILS", + ), + NormalizedList( + "SSMInstancePatch", "cve_ids", "SSMInstancePatchCveIdsItem", "HAS_CVE_IDS" + ), + NormalizedList( + "ACMCertificate", "in_use_by", "ACMCertificateInUseByItem", "HAS_IN_USE_BY" + ), + NormalizedList( + "APIGatewayRestAPI", + "anonymous_actions", + "APIGatewayRestAPIAnonymousActionsItem", + "HAS_ANONYMOUS_ACTIONS", + ), + NormalizedList( + "GlueJob", "connections", "GlueJobConnectionsItem", "HAS_CONNECTIONS" + ), + NormalizedList( + "AWSBedrockFoundationModel", + "input_modalities", + "AWSBedrockFoundationModelInputModalitiesItem", + "HAS_INPUT_MODALITIES", + ), + NormalizedList( + "AWSBedrockFoundationModel", + "output_modalities", + "AWSBedrockFoundationModelOutputModalitiesItem", + "HAS_OUTPUT_MODALITIES", + ), + NormalizedList( + "AWSBedrockFoundationModel", + "customizations_supported", + "AWSBedrockFoundationModelCustomizationsSupportedItem", + "HAS_CUSTOMIZATIONS_SUPPORTED", + ), + NormalizedList( + "AWSBedrockFoundationModel", + "inference_types_supported", + "AWSBedrockFoundationModelInferenceTypesSupportedItem", + "HAS_INFERENCE_TYPES_SUPPORTED", + ), +] + + +AWS_CONFIG = ProviderConfig( + name="aws", + root_node_label="AWSAccount", + uid_field="arn", + resource_label="_AWSResource", + ingestion_function=aws.start_aws_ingestion, + short_uid_extractor=aws.extract_short_uid, + normalized_lists=AWS_NORMALIZED_LISTS, +) + + +PROVIDER_CONFIGS: dict[str, ProviderConfig] = { + "aws": AWS_CONFIG, +} diff --git a/api/src/backend/tasks/jobs/attack_paths/queries.py b/api/src/backend/tasks/jobs/attack_paths/queries.py index 277305f0e0..1166de17ed 100644 --- a/api/src/backend/tasks/jobs/attack_paths/queries.py +++ b/api/src/backend/tasks/jobs/attack_paths/queries.py @@ -1,8 +1,6 @@ # Cypher query templates for Attack Paths operations from tasks.jobs.attack_paths.config import ( INTERNET_NODE_LABEL, - PROVIDER_ELEMENT_ID_PROPERTY, - PROVIDER_RESOURCE_LABEL, PROWLER_FINDING_LABEL, ) @@ -21,7 +19,6 @@ def render_cypher_template(template: str, replacements: dict[str, str]) -> str: # Findings queries (used by findings.py) -# --------------------------------------- ADD_RESOURCE_LABEL_TEMPLATE = """ MATCH (account:__ROOT_LABEL__ {id: $provider_uid})-->(r) @@ -88,7 +85,6 @@ INSERT_FINDING_TEMPLATE = f""" """ # Internet queries (used by internet.py) -# --------------------------------------- CREATE_INTERNET_NODE = f""" MERGE (internet:{INTERNET_NODE_LABEL} {{id: 'Internet'}}) @@ -118,8 +114,8 @@ CREATE_CAN_ACCESS_RELATIONSHIPS_TEMPLATE = f""" RETURN COUNT(r) AS relationships_merged """ -# Sync queries (used by sync.py) -# ------------------------------- +# Sync queries (used by sync.py to fetch from the cartography temp Neo4j DB) +# The write side of sync lives in each sink (`api/attack_paths/sink/`). NODE_FETCH_QUERY = """ MATCH (n) @@ -143,17 +139,3 @@ RELATIONSHIPS_FETCH_QUERY = """ ORDER BY internal_id LIMIT $batch_size """ - -NODE_SYNC_TEMPLATE = f""" - UNWIND $rows AS row - MERGE (n:__NODE_LABELS__ {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.provider_element_id}}) - SET n += row.props -""" - -RELATIONSHIP_SYNC_TEMPLATE = f""" - UNWIND $rows AS row - MATCH (s:{PROVIDER_RESOURCE_LABEL} {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.start_element_id}}) - MATCH (t:{PROVIDER_RESOURCE_LABEL} {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.end_element_id}}) - MERGE (s)-[r:__REL_TYPE__ {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.provider_element_id}}]->(t) - SET r += row.props -""" diff --git a/api/src/backend/tasks/jobs/attack_paths/scan.py b/api/src/backend/tasks/jobs/attack_paths/scan.py index 0fb8d2b885..32337c6832 100644 --- a/api/src/backend/tasks/jobs/attack_paths/scan.py +++ b/api/src/backend/tasks/jobs/attack_paths/scan.py @@ -39,8 +39,8 @@ Pipeline steps: 7. Sync the temp database into the tenant database: - Drop the old provider subgraph (matched by dynamic _Provider_{uuid} label). - graph_data_ready is set to False for all scans of this provider while - the swap happens so the API doesn't serve partial data. + graph_data_ready is set to False for scans of this provider in the + target sink while the swap happens so the API doesn't serve partial data. - Copy nodes and relationships in batches. Every synced node gets a _ProviderResource label and dynamic _Tenant_{uuid} / _Provider_{uuid} isolation labels, plus a _provider_element_id property for MERGE keys. @@ -64,10 +64,17 @@ from api.models import StateChoices from api.utils import initialize_prowler_provider from cartography.config import Config as CartographyConfig from cartography.intel import analysis as cartography_analysis -from cartography.intel import create_indexes as cartography_create_indexes from cartography.intel import ontology as cartography_ontology from celery.utils.log import get_task_logger -from tasks.jobs.attack_paths import db_utils, findings, indexes, internet, sync, utils +from django.conf import settings +from tasks.jobs.attack_paths import ( + db_utils, + findings, + indexes, + internet, + sync, + utils, +) from tasks.jobs.attack_paths.config import get_cartography_ingestion_function # Without this Celery goes crazy with Cartography logging @@ -96,7 +103,7 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: attack_paths_scan = db_utils.retrieve_attack_paths_scan(tenant_id, scan_id) # Idempotency guard: cleanup may have flipped this row to a terminal state - # while the message was still in flight. Bail out before touching state. + # while the message was still in flight. Bail out before touching state if attack_paths_scan and attack_paths_scan.state in ( StateChoices.FAILED, StateChoices.COMPLETED, @@ -125,7 +132,7 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: else: if not attack_paths_scan: - # Safety net for in-flight messages or direct task invocations; dispatcher normally pre-creates the row. + # Safety net for in-flight messages or direct task invocations; dispatcher normally pre-creates the row logger.warning( f"No Attack Paths Scan found for scan {scan_id} and tenant {tenant_id}, let's create it then" ) @@ -143,10 +150,18 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: tenant_database_name = graph_database.get_database_name( prowler_api_provider.tenant_id ) + target_sink_backend = settings.ATTACK_PATHS_SINK_DATABASE + target_description = ( + f"tenant Neo4j database {tenant_database_name}" + if target_sink_backend == "neo4j" + else f"{target_sink_backend} sink" + ) # While creating the Cartography configuration, attributes `neo4j_user` and `neo4j_password` are not really needed in this config object tmp_cartography_config = CartographyConfig( - neo4j_uri=graph_database.get_uri(), + # The temp ingest database is always Neo4j, so use the ingest URI here + # rather than the sink URI (which points at Neptune when configured). + neo4j_uri=graph_database.get_ingest_uri(), neo4j_database=tmp_database_name, update_tag=int(time.time()), ) @@ -156,6 +171,8 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: update_tag=tmp_cartography_config.update_tag, ) + graph_database.verify_scan_databases_available() + # Starting the Attack Paths scan if not db_utils.starting_attack_paths_scan( attack_paths_scan, tenant_cartography_config @@ -168,7 +185,8 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: scan_t0 = time.perf_counter() logger.info( f"Starting Attack Paths scan ({attack_paths_scan.id}) for " - f"{prowler_api_provider.provider.upper()} provider {prowler_api_provider.id}" + f"{prowler_api_provider.provider.upper()} provider {prowler_api_provider.id} " + f"(staging=Neo4j database {tmp_database_name}, target={target_description})" ) subgraph_dropped = False @@ -177,7 +195,8 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: try: logger.info( - f"Creating Neo4j database {tmp_cartography_config.neo4j_database} for tenant {prowler_api_provider.tenant_id}" + f"Creating staging Neo4j database {tmp_cartography_config.neo4j_database} " + f"for tenant {prowler_api_provider.tenant_id}" ) graph_database.create_database(tmp_cartography_config.neo4j_database) @@ -191,7 +210,9 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: tmp_cartography_config.neo4j_database ) as tmp_neo4j_session: # Indexes creation - cartography_create_indexes.run(tmp_neo4j_session, tmp_cartography_config) + indexes.create_cartography_indexes( + tmp_neo4j_session, tmp_cartography_config + ) indexes.create_findings_indexes(tmp_neo4j_session) db_utils.update_attack_paths_scan_progress(attack_paths_scan, 2) @@ -223,7 +244,7 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: cartography_analysis.run(tmp_neo4j_session, tmp_cartography_config) db_utils.update_attack_paths_scan_progress(attack_paths_scan, 95) - # Creating Internet node and CAN_ACCESS relationships + # Creating Internet node and `CAN_ACCESS` relationships logger.info( f"Creating Internet graph for AWS account {prowler_api_provider.uid}" ) @@ -247,23 +268,41 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: db_utils.update_attack_paths_scan_progress(attack_paths_scan, 97) logger.info( - f"Clearing Neo4j cache for database {tmp_cartography_config.neo4j_database}" + f"Clearing Neo4j cache for staging database {tmp_cartography_config.neo4j_database}" ) graph_database.clear_cache(tmp_cartography_config.neo4j_database) + t0 = time.perf_counter() logger.info( - f"Ensuring tenant database {tenant_database_name}, and its indexes, exists for tenant {prowler_api_provider.tenant_id}" + f"Preparing target {target_description} for tenant {prowler_api_provider.tenant_id}" ) graph_database.create_database(tenant_database_name) - with graph_database.get_session(tenant_database_name) as tenant_neo4j_session: - cartography_create_indexes.run( - tenant_neo4j_session, tenant_cartography_config - ) - indexes.create_findings_indexes(tenant_neo4j_session) - indexes.create_sync_indexes(tenant_neo4j_session) + # Sink-side index creation: Neptune auto-manages indexes and rejects + # `CREATE INDEX`, so only run it when the sink is Neo4j + # The temp ingest DB is always Neo4j and is always indexed above + if target_sink_backend != "neptune": + logger.info(f"Ensuring indexes exist for {target_description}") + with graph_database.get_session( + tenant_database_name + ) as tenant_neo4j_session: + indexes.create_cartography_indexes( + tenant_neo4j_session, tenant_cartography_config + ) + indexes.create_findings_indexes(tenant_neo4j_session) + indexes.create_sync_indexes(tenant_neo4j_session) + else: + logger.info("Skipping tenant database indexes for neptune sink") + logger.info( + f"Prepared target {target_description} in {time.perf_counter() - t0:.3f}s" + ) - logger.info(f"Deleting existing provider graph in {tenant_database_name}") - db_utils.set_provider_graph_data_ready(attack_paths_scan, False) + logger.info( + f"Deleting existing provider graph from {target_description} " + f"(tenant={prowler_api_provider.tenant_id}, provider={prowler_api_provider.id})" + ) + db_utils.set_provider_graph_data_ready( + attack_paths_scan, False, target_sink_backend + ) provider_gated = True t0 = time.perf_counter() @@ -272,14 +311,17 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: provider_id=str(prowler_api_provider.id), ) logger.info( - f"Deleted existing provider graph in {time.perf_counter() - t0:.3f}s " - f"(deleted_nodes={deleted_nodes})" + f"Deleted existing provider graph from {target_description} " + f"in {time.perf_counter() - t0:.3f}s (deleted_nodes={deleted_nodes})" ) subgraph_dropped = True db_utils.update_attack_paths_scan_progress(attack_paths_scan, 98) logger.info( - f"Syncing graph from {tmp_database_name} into {tenant_database_name}" + f"Syncing staging graph {tmp_database_name} into {target_description} " + f"for provider {prowler_api_provider.id} " + f"(tenant {prowler_api_provider.tenant_id}, " + f"type {prowler_api_provider.provider})" ) t0 = time.perf_counter() sync_result = sync.sync_graph( @@ -287,17 +329,34 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: target_database=tenant_database_name, tenant_id=str(prowler_api_provider.tenant_id), provider_id=str(prowler_api_provider.id), + provider_type=prowler_api_provider.provider, ) + elapsed = time.perf_counter() - t0 + total_nodes = sync_result["nodes"] + sync_result["child_nodes"] + elements = total_nodes + sync_result["relationships"] + rate = elements / elapsed if elapsed else 0 logger.info( - f"Synced graph in {time.perf_counter() - t0:.3f}s " - f"(nodes={sync_result['nodes']}, relationships={sync_result['relationships']})" + f"Synced staging graph into {target_description} in {elapsed:.3f}s - " + f"nodes={total_nodes} (source={sync_result['nodes']}, " + f"items={sync_result['child_nodes']}), " + f"relationships={sync_result['relationships']} " + f"(structural={sync_result['structural_relationships']}, " + f"items={sync_result['item_relationships']}), " + f"~{rate:.0f} elem/s" ) sync_completed = True + # Flip metadata only now: the new schema is live in the target sink, so + # reads can switch to the current catalog/backend. The target-sink gate + # is already closed, so the switch is atomic from the API's view. + db_utils.set_scan_migrated(attack_paths_scan, True, target_sink_backend) db_utils.set_graph_data_ready(attack_paths_scan, True) db_utils.update_attack_paths_scan_progress(attack_paths_scan, 99) - logger.info(f"Clearing Neo4j cache for database {tenant_database_name}") - graph_database.clear_cache(tenant_database_name) + if target_sink_backend == "neptune": + logger.info("Skipping cache clear for neptune sink") + else: + logger.info(f"Clearing Neo4j cache for target {target_description}") + graph_database.clear_cache(tenant_database_name) logger.info(f"Dropping temporary Neo4j database {tmp_database_name}") graph_database.drop_database(tmp_database_name) @@ -316,14 +375,16 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: logger.exception(exception_message) ingestion_exceptions["global_error"] = exception_message - # Recover graph_data_ready based on how far the swap got. - # Partial drop (mid-batch failure) may leave `subgraph_dropped=False` - # with data partially deleted, so we prefer that over permanently blocked queries. + # Recover `graph_data_ready` based on how far the swap got + # Partial drop (mid-batch failure) may leave `subgraph_dropped=False` with data partially deleted, + # so we prefer that over permanently blocked queries try: if sync_completed: db_utils.set_graph_data_ready(attack_paths_scan, True) elif provider_gated and not subgraph_dropped: - db_utils.set_provider_graph_data_ready(attack_paths_scan, True) + db_utils.set_provider_graph_data_ready( + attack_paths_scan, True, target_sink_backend + ) except Exception: logger.error( diff --git a/api/src/backend/tasks/jobs/attack_paths/sync.py b/api/src/backend/tasks/jobs/attack_paths/sync.py index 50f770deb5..7b73fa21e2 100644 --- a/api/src/backend/tasks/jobs/attack_paths/sync.py +++ b/api/src/backend/tasks/jobs/attack_paths/sync.py @@ -1,40 +1,58 @@ """ Graph sync operations for Attack Paths. -This module handles syncing graph data from temporary scan databases -to the tenant database, adding provider isolation labels and properties. +Reads nodes and relationships out of the cartography temp database (always +Neo4j) and hands them to the configured sink (Neo4j or Neptune) in batches. +Backend-specific Cypher (MERGE shape, ID strategy, indexes) lives in each +sink; this module owns the source read loop, per-batch grouping, and the +list-property materialisation policy (see `NormalizedList`). + +Each list-typed node property that appears in the provider's +`normalized_lists` catalog becomes a set of child item nodes connected to +the parent by a typed edge. A list-typed property that is not in the +catalog is serialised to a comma-delimited string and emits a one-time +warning per (label, property), surfacing Cartography fields that should be +added to the catalog. """ +import json import time from collections import defaultdict +from collections.abc import Iterator from typing import Any import neo4j from api.attack_paths import database as graph_database +from api.attack_paths import sink as sink_module from celery.utils.log import get_task_logger from tasks.jobs.attack_paths.config import ( + PROVIDER_CONFIGS, PROVIDER_ISOLATION_PROPERTIES, PROVIDER_RESOURCE_LABEL, SYNC_BATCH_SIZE, + NormalizedList, get_provider_label, get_tenant_label, ) from tasks.jobs.attack_paths.queries import ( NODE_FETCH_QUERY, - NODE_SYNC_TEMPLATE, - RELATIONSHIP_SYNC_TEMPLATE, RELATIONSHIPS_FETCH_QUERY, - render_cypher_template, ) logger = get_task_logger(__name__) +# (label, property) tuples for which we've already emitted the +# "unnormalised list" warning. Module-level so the warning fires once per +# process, not once per node. +_WARNED_UNNORMALIZED: set[tuple[str, str]] = set() + def sync_graph( source_database: str, target_database: str, tenant_id: str, provider_id: str, + provider_type: str, ) -> dict[str, int]: """ Sync all nodes and relationships from source to target database. @@ -44,25 +62,38 @@ def sync_graph( `target_database`: The tenant database `tenant_id`: The tenant ID for isolation `provider_id`: The provider ID for isolation + `provider_type`: Provider type key (e.g. "aws"), used to resolve the + `NormalizedList` catalog from `PROVIDER_CONFIGS`. Returns: - Dict with counts of synced nodes and relationships + Dict with counts of synced nodes, child item nodes, and relationships. """ - nodes_synced = sync_nodes( + sink = sink_module.get_backend() + sink.ensure_sync_indexes(target_database) + + normalized_lists = _resolve_normalized_lists(provider_type) + + node_result = sync_nodes( source_database, target_database, tenant_id, provider_id, + sink, + normalized_lists, ) relationships_synced = sync_relationships( source_database, target_database, provider_id, + sink, ) return { - "nodes": nodes_synced, - "relationships": relationships_synced, + "nodes": node_result["parents"], + "child_nodes": node_result["children"], + "relationships": relationships_synced + node_result["parent_child_rels"], + "structural_relationships": relationships_synced, + "item_relationships": node_result["parent_child_rels"], } @@ -71,22 +102,35 @@ def sync_nodes( target_database: str, tenant_id: str, provider_id: str, -) -> int: + sink: Any, + normalized_lists: list[NormalizedList], +) -> dict[str, int]: """ - Sync nodes from source to target database. + Sync nodes from source to target database, exploding catalogued list + properties into child nodes + parent->child edges. Adds `_ProviderResource` label and dynamic `_Tenant_{id}` and `_Provider_{id}` - isolation labels to all nodes. + isolation labels to all nodes (parents and children alike). Source and target sessions are opened sequentially per batch to avoid holding two Bolt connections simultaneously for the entire sync duration. """ t0 = time.perf_counter() last_id = -1 - total_synced = 0 + parents_synced = 0 + children_synced = 0 + parent_child_rels = 0 + + catalog = _build_catalog_index(normalized_lists) + extra_labels = _build_extra_labels(tenant_id, provider_id) while True: - grouped: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list) + tb = time.perf_counter() + prev_children = children_synced + prev_rels = parent_child_rels + parent_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list) + child_groups: dict[str, list[dict[str, Any]]] = defaultdict(list) + rel_groups: dict[str, list[dict[str, Any]]] = defaultdict(list) batch_count = 0 with graph_database.get_session(source_database) as source_session: @@ -97,43 +141,66 @@ def sync_nodes( for record in result: batch_count += 1 last_id = record["internal_id"] - key, value = _node_to_sync_dict(record, provider_id) - grouped[key].append(value) + key, parent_dict, children, rels = _node_to_sync_dict( + record, provider_id, catalog + ) + parent_groups[key].append(parent_dict) + for child in children: + child_groups[child["_child_label"]].append(child["row"]) + for rel in rels: + rel_groups[rel["rel_type"]].append(rel["row"]) if batch_count == 0: break - with graph_database.get_session(target_database) as target_session: - for labels, batch in grouped.items(): - label_set = set(labels) - label_set.add(PROVIDER_RESOURCE_LABEL) - label_set.add(get_tenant_label(tenant_id)) - label_set.add(get_provider_label(provider_id)) - node_labels = ":".join(f"`{label}`" for label in sorted(label_set)) + for labels, batch in parent_groups.items(): + rendered_labels = _render_labels(labels, extra_labels) + for sink_batch in _iter_sink_batches(batch): + sink.write_nodes(target_database, rendered_labels, sink_batch) - query = render_cypher_template( - NODE_SYNC_TEMPLATE, {"__NODE_LABELS__": node_labels} + for child_label, batch in child_groups.items(): + rendered_labels = _render_labels((child_label,), extra_labels) + for sink_batch in _iter_sink_batches(batch): + sink.write_nodes(target_database, rendered_labels, sink_batch) + children_synced += len(batch) + + for rel_type, batch in rel_groups.items(): + for sink_batch in _iter_sink_batches(batch): + sink.write_relationships( + target_database, rel_type, provider_id, sink_batch ) - target_session.run(query, {"rows": batch}) + parent_child_rels += len(batch) - total_synced += batch_count + parents_synced += batch_count + batch_dt = time.perf_counter() - tb + batch_elements = ( + batch_count + + (children_synced - prev_children) + + (parent_child_rels - prev_rels) + ) + rate = batch_elements / batch_dt if batch_dt else 0 logger.info( - f"Synced {total_synced} nodes from {source_database} to {target_database} in {time.perf_counter() - t0:.3f}s" + f"[sync nodes] {parents_synced} source (+{children_synced} items, " + f"+{parent_child_rels} item rels) · batch {batch_dt:.1f}s · " + f"elapsed {time.perf_counter() - t0:.1f}s · ~{rate:.0f} elem/s" ) - return total_synced + return { + "parents": parents_synced, + "children": children_synced, + "parent_child_rels": parent_child_rels, + } def sync_relationships( source_database: str, target_database: str, provider_id: str, + sink: Any, ) -> int: """ Sync relationships from source to target database. - Matches source and target nodes by `_provider_element_id` in the tenant database. - Source and target sessions are opened sequentially per batch to avoid holding two Bolt connections simultaneously for the entire sync duration. """ @@ -142,6 +209,7 @@ def sync_relationships( total_synced = 0 while True: + tb = time.perf_counter() grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) batch_count = 0 @@ -159,32 +227,213 @@ def sync_relationships( if batch_count == 0: break - with graph_database.get_session(target_database) as target_session: - for rel_type, batch in grouped.items(): - query = render_cypher_template( - RELATIONSHIP_SYNC_TEMPLATE, {"__REL_TYPE__": rel_type} + for rel_type, batch in grouped.items(): + for sink_batch in _iter_sink_batches(batch): + sink.write_relationships( + target_database, rel_type, provider_id, sink_batch ) - target_session.run(query, {"rows": batch}) total_synced += batch_count + batch_dt = time.perf_counter() - tb + rate = batch_count / batch_dt if batch_dt else 0 logger.info( - f"Synced {total_synced} relationships from {source_database} to {target_database} in {time.perf_counter() - t0:.3f}s" + f"[sync rels] {total_synced} structural · batch {batch_dt:.1f}s · " + f"elapsed {time.perf_counter() - t0:.1f}s · ~{rate:.0f}/s" ) return total_synced +def _iter_sink_batches( + rows: list[dict[str, Any]], + batch_size: int | None = None, +) -> Iterator[list[dict[str, Any]]]: + """Yield final sink write batches after source rows have been transformed.""" + batch_size = SYNC_BATCH_SIZE if batch_size is None else batch_size + if batch_size <= 0: + raise ValueError("Sink batch size must be greater than zero") + + for index in range(0, len(rows), batch_size): + yield rows[index : index + batch_size] + + def _node_to_sync_dict( - record: neo4j.Record, provider_id: str -) -> tuple[tuple[str, ...], dict[str, Any]]: - """Transform a source node record into a (grouping_key, sync_dict) pair.""" + record: neo4j.Record, + provider_id: str, + catalog: dict[tuple[str, str], NormalizedList], +) -> tuple[ + tuple[str, ...], + dict[str, Any], + list[dict[str, Any]], + list[dict[str, Any]], +]: + """Transform a source node record into a (grouping_key, sync_dict, children, rels) tuple. + + Catalogued list properties are popped from `props` and emitted as child + nodes + parent->child relationships. + """ props = dict(record["props"] or {}) _strip_internal_properties(props) labels = tuple(sorted(set(record["labels"] or []))) - return labels, { - "provider_element_id": f"{provider_id}:{record['element_id']}", + parent_element_id = f"{provider_id}:{record['element_id']}" + + children, rels = _explode_catalogued_lists( + labels, props, catalog, provider_id, parent_element_id + ) + + _normalize_sink_properties(props, labels) + + parent = { + "provider_element_id": parent_element_id, "props": props, } + return labels, parent, children, rels + + +def _explode_catalogued_lists( + labels: tuple[str, ...], + props: dict[str, Any], + catalog: dict[tuple[str, str], NormalizedList], + provider_id: str, + parent_element_id: str, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Pop catalogued list properties from `props` and produce child + rel emits. + + A node may carry multiple labels (e.g. `AWSPolicyStatement` plus + `_AWSResource`); we check each label for catalog matches independently. + Returns: + - children: list of {"_child_label": str, "row": } dicts. + - rels: list of {"rel_type": str, "row": } dicts. + """ + children: list[dict[str, Any]] = [] + rels: list[dict[str, Any]] = [] + + for label in labels: + for key in list(props.keys()): + spec = catalog.get((label, key)) + if spec is None: + continue + value = props.pop(key) + if value is None: + continue + if not isinstance(value, list): + # Catalogued but not actually a list this scan - fall back to + # the generic normaliser so we don't lose the value. + props[key] = value + continue + for item in value: + child_value_key, child_props = _build_child_props(spec, item) + if child_value_key is None: + continue + child_element_id = _build_child_id( + provider_id, spec.child_label, child_value_key + ) + children.append( + { + "_child_label": spec.child_label, + "row": { + "provider_element_id": child_element_id, + "props": child_props, + }, + } + ) + rels.append( + { + "rel_type": spec.rel_type, + "row": { + "start_element_id": parent_element_id, + "end_element_id": child_element_id, + "provider_element_id": ( + f"{parent_element_id}::{spec.rel_type}::" + f"{child_element_id}" + ), + "props": {}, + }, + } + ) + + return children, rels + + +def _build_child_props( + spec: NormalizedList, item: Any +) -> tuple[str | None, dict[str, Any]]: + """Translate one list element into a child node's prop dict. + + Returns (dedup_key, props). The dedup_key is what makes two child nodes + equal within (tenant, provider) - used to build `_provider_element_id`. + For scalar mode, the dedup key is the value itself. For dict mode it is + a stable concatenation of the mapped fields in `field_map` order. + """ + if not spec.field_map: + if isinstance(item, (dict, list)): + # Defensive: caller marked this list as scalar but elements are + # structured. Convert to a stable string so the value survives. + value_str = json.dumps(item, sort_keys=True, default=str) + else: + value_str = str(item) + return value_str, {"value": value_str} + + if not isinstance(item, dict): + # Catalogued as dict-shape but got a scalar. Skip - caller will see + # the value go missing and can fix the field_map. + return None, {} + + props: dict[str, Any] = {} + dedup_parts: list[str] = [] + for src_key, child_field in spec.field_map: + raw = item.get(src_key) + value_str = _to_sink_property_value(raw) if raw is not None else "" + props[child_field] = value_str + dedup_parts.append(f"{child_field}={value_str}") + return "::".join(dedup_parts), props + + +def _build_child_id(provider_id: str, child_label: str, value_key: str) -> str: + """Deterministic `_provider_element_id` for a list-item child node. + + Dedupes within (tenant, provider): multiple parents referencing the same + value share one child node via the existing MERGE-on-_provider_element_id + index in both sinks. + """ + return f"{provider_id}::{child_label}::{value_key}" + + +def _build_catalog_index( + normalized_lists: list[NormalizedList], +) -> dict[tuple[str, str], NormalizedList]: + """Index the catalog by (source_label, source_property) for O(1) lookup.""" + return { + (spec.source_label, spec.source_property): spec for spec in normalized_lists + } + + +def _build_extra_labels(tenant_id: str, provider_id: str) -> tuple[str, ...]: + return ( + PROVIDER_RESOURCE_LABEL, + get_tenant_label(tenant_id), + get_provider_label(provider_id), + ) + + +def _render_labels(base_labels: tuple[str, ...], extra_labels: tuple[str, ...]) -> str: + """Render the Cypher label string for a node-write batch.""" + label_set = set(base_labels) | set(extra_labels) + return ":".join(f"`{label}`" for label in sorted(label_set)) + + +def _resolve_normalized_lists(provider_type: str) -> list[NormalizedList]: + config = PROVIDER_CONFIGS.get(provider_type) + if config is None: + # Unknown provider: empty catalog. Any list-typed property will be + # serialised to a comma-delimited string with one warning per + # (label, property). + logger.warning( + "Provider type %s not in PROVIDER_CONFIGS; no normalized_lists active", + provider_type, + ) + return [] + return config.normalized_lists def _rel_to_sync_dict( @@ -193,7 +442,11 @@ def _rel_to_sync_dict( """Transform a source relationship record into a (grouping_key, sync_dict) pair.""" props = dict(record["props"] or {}) _strip_internal_properties(props) + # Relationship properties go through the same primitive coercion as + # nodes; catalog-driven materialisation applies to node properties only. + _normalize_sink_properties(props, labels=None) rel_type = record["rel_type"] + return rel_type, { "start_element_id": f"{provider_id}:{record['start_element_id']}", "end_element_id": f"{provider_id}:{record['end_element_id']}", @@ -206,3 +459,80 @@ def _strip_internal_properties(props: dict[str, Any]) -> None: """Remove provider isolation properties before the += spread in sync templates.""" for key in PROVIDER_ISOLATION_PROPERTIES: props.pop(key, None) + + +def _normalize_sink_properties( + props: dict[str, Any], labels: tuple[str, ...] | None +) -> None: + """Normalize property values to primitive Cypher literals for either sink. + + Attack-paths node and relationship properties are written as primitive + scalars regardless of the active sink (Neo4j or Neptune). The convention + is driven by Neptune's openCypher type restrictions, which reject list, + map, temporal and spatial property values, but it is applied uniformly + so that custom and predefined queries are portable across sinks without + runtime rewriting. + + Concretely: + - Temporal values (neo4j.time.{DateTime,Date,Time,Duration}) become + their ISO-8601 string representation. + - Spatial values (neo4j.spatial.Point and subclasses) become their + WKT-style string representation. + - Maps / dicts become a JSON-encoded string, read back with `CONTAINS` + substring checks inside queries. + - Lists become a comma-delimited string. Catalogued list properties + are materialised as child item nodes upstream in + `_explode_catalogued_lists` and never reach this point; any list + seen here is uncatalogued, so we log a one-time warning per + (label, property) to surface Cartography fields that should be + added to the catalog. + + `labels` is only used for the warning message; pass `None` for + relationship props (no label context). + """ + for key, value in list(props.items()): + if isinstance(value, list) and labels is not None: + _warn_unnormalized_list(labels, key) + props[key] = _to_sink_property_value(value) + + +def _warn_unnormalized_list(labels: tuple[str, ...], key: str) -> None: + """Warn once per (label, property), on the real label(s) only. + + Every synced node also carries internal isolation labels (`_AWSResource`, + `_ProviderResource`, `_Tenant_*`, `_Provider_*`); warning on those just + doubles the noise, so skip them and point at the actionable Cartography + label. Falls back to all labels if only internal ones are present. + """ + real_labels = [label for label in labels if not label.startswith("_")] + for label in real_labels or labels: + token = (label, key) + if token in _WARNED_UNNORMALIZED: + continue + _WARNED_UNNORMALIZED.add(token) + logger.warning( + "Unnormalized list property %s.%s reached sink as comma-string; " + "add a NormalizedList entry to the provider catalog to explode it", + label, + key, + ) + + +def _to_sink_property_value(value: Any) -> Any: + if hasattr(value, "iso_format") and callable(value.iso_format): + return value.iso_format() + + if type(value).__module__.startswith("neo4j.spatial"): + return str(value) + + if isinstance(value, dict): + # openCypher `SET` rejects map property values: encode as JSON so the structured payload + # survives the round-trip and is queryable with `CONTAINS` substring checks + return json.dumps(value, sort_keys=True, default=str) + + if isinstance(value, list): + # openCypher `SET` rejects list/array property values: encode as a + # delimited string read back with split() inside queries + return ",".join(str(_to_sink_property_value(v)) for v in value) + + return value diff --git a/api/src/backend/tasks/jobs/deletion.py b/api/src/backend/tasks/jobs/deletion.py index fadf98d464..91e64610f7 100644 --- a/api/src/backend/tasks/jobs/deletion.py +++ b/api/src/backend/tasks/jobs/deletion.py @@ -1,4 +1,5 @@ from api.attack_paths import database as graph_database +from api.attack_paths import sink as sink_module from api.db_router import MainRouter from api.db_utils import batch_delete, rls_transaction from api.models import ( @@ -76,6 +77,12 @@ def delete_provider(tenant_id: str, pk: str): "id", flat=True ) ) + attack_paths_sink_backends = list( + AttackPathsScan.all_objects.filter(provider=instance) + .values_list("sink_backend", flat=True) + .distinct() + .order_by("sink_backend") + ) deletion_steps = [ ("Scan Summaries", ScanSummary.all_objects.filter(scan__provider=instance)), @@ -97,7 +104,13 @@ def delete_provider(tenant_id: str, pk: str): # Delete the Attack Paths' graph data related to the provider from the tenant database tenant_database_name = graph_database.get_database_name(tenant_id) try: - graph_database.drop_subgraph(tenant_database_name, str(pk)) + if attack_paths_sink_backends: + for sink_backend in attack_paths_sink_backends: + sink_module.get_backend_for_name(sink_backend).drop_subgraph( + tenant_database_name, str(pk) + ) + else: + graph_database.drop_subgraph(tenant_database_name, str(pk)) except graph_database.GraphDatabaseQueryException as gdb_error: logger.error(f"Error deleting Provider graph data: {gdb_error}") diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index dcaacf6642..d69e0c8941 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -19,7 +19,7 @@ from api.db_utils import ( psycopg_connection, rls_transaction, ) -from api.exceptions import ProviderConnectionError +from api.exceptions import ProviderConnectionError, ProviderDeletedException from api.models import ( AttackSurfaceOverview, ComplianceOverviewSummary, @@ -48,7 +48,7 @@ from celery.utils.log import get_task_logger from config.django.base import DJANGO_FINDINGS_BATCH_SIZE from config.env import env from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS -from django.db import IntegrityError, OperationalError +from django.db import DatabaseError, IntegrityError, OperationalError, transaction from django.db.models import ( Case, Count, @@ -117,6 +117,20 @@ ATTACK_SURFACE_PROVIDER_COMPATIBILITY = { _ATTACK_SURFACE_MAPPING_CACHE: dict[str, dict] = {} +def _save_scan_instance( + scan_instance: Scan, provider_id: str, update_fields: list[str] +) -> None: + try: + with transaction.atomic(): # Savepoint for not killing the `rls_transaction` + scan_instance.save(update_fields=update_fields) + except DatabaseError: + if Scan.objects.filter(pk=scan_instance.id).exists(): + raise + raise ProviderDeletedException( + f"Provider '{provider_id}' for scan '{scan_instance.id}' was deleted during the scan" + ) from None + + def aggregate_category_counts( categories: list[str], severity: str, @@ -1029,13 +1043,18 @@ def perform_prowler_scan( group_resources_cache: dict[str, set] = {} start_time = time.time() exc = None + skip_final_scan_update = False with rls_transaction(tenant_id): provider_instance = Provider.objects.get(pk=provider_id) scan_instance = Scan.objects.get(pk=scan_id) scan_instance.state = StateChoices.EXECUTING scan_instance.started_at = datetime.now(tz=UTC) - scan_instance.save(update_fields=["state", "started_at", "updated_at"]) + _save_scan_instance( + scan_instance, + provider_id, + ["state", "started_at", "updated_at"], + ) # Find the mutelist processor if it exists with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): @@ -1101,7 +1120,7 @@ def perform_prowler_scan( # Throttle scan_instance progress writes to avoid hammering the writer: # only persist when progress moves by at least `PROGRESS_THROTTLE_DELTA` - # OR `PROGRESS_THROTTLE_SECONDS` have elapsed. The final progress (1.0) + # OR `PROGRESS_THROTTLE_SECONDS` have elapsed. The final progress (100) # always persists in the `finally` block below. last_persisted_progress = -1.0 last_persisted_progress_at = 0.0 @@ -1143,7 +1162,11 @@ def perform_prowler_scan( ): with rls_transaction(tenant_id): scan_instance.progress = progress - scan_instance.save(update_fields=["progress", "updated_at"]) + _save_scan_instance( + scan_instance, + provider_id, + ["progress", "updated_at"], + ) last_persisted_progress = progress last_persisted_progress_at = now @@ -1170,26 +1193,39 @@ def perform_prowler_scan( batch_size=SCAN_DB_BATCH_SIZE, ) + except ProviderDeletedException as e: + logger.warning(str(e)) + exception = e + skip_final_scan_update = True except Exception as e: logger.error(f"Error performing scan {scan_id}: {e}") exception = e scan_instance.state = StateChoices.FAILED finally: - with rls_transaction(tenant_id): - scan_instance.duration = time.time() - start_time - scan_instance.completed_at = datetime.now(tz=UTC) - scan_instance.unique_resource_count = len(unique_resources) - scan_instance.save( - update_fields=[ - "state", - "duration", - "completed_at", - "unique_resource_count", - "progress", - "updated_at", - ] - ) + if not skip_final_scan_update: + try: + with rls_transaction(tenant_id): + scan_instance.duration = time.time() - start_time + scan_instance.completed_at = datetime.now(tz=UTC) + scan_instance.unique_resource_count = len(unique_resources) + if exception is None: + scan_instance.progress = 100 + _save_scan_instance( + scan_instance, + provider_id, + [ + "state", + "duration", + "completed_at", + "unique_resource_count", + "progress", + "updated_at", + ], + ) + except ProviderDeletedException as e: + logger.warning(str(e)) + exception = e if exception is not None: raise exception diff --git a/api/src/backend/tasks/tests/test_attack_paths_provider_config.py b/api/src/backend/tasks/tests/test_attack_paths_provider_config.py new file mode 100644 index 0000000000..41ec2847d4 --- /dev/null +++ b/api/src/backend/tasks/tests/test_attack_paths_provider_config.py @@ -0,0 +1,30 @@ +from tasks.jobs.attack_paths.provider_config import AWS_NORMALIZED_LISTS +from tasks.jobs.attack_paths.sync import _build_catalog_index, _node_to_sync_dict + + +def test_aws_vpc_endpoint_id_lists_are_normalized(): + catalog = _build_catalog_index(AWS_NORMALIZED_LISTS) + record = { + "element_id": "node-1", + "labels": ["AWSVpcEndpoint"], + "props": { + "id": "vpce-123", + "route_table_ids": ["rtb-1"], + "network_interface_ids": ["eni-1"], + "subnet_ids": ["subnet-1"], + }, + } + + _, parent, children, rels = _node_to_sync_dict(record, "provider-id", catalog) + + assert parent["props"] == {"id": "vpce-123"} + assert {child["_child_label"] for child in children} == { + "AWSVpcEndpointRouteTableIdsItem", + "AWSVpcEndpointNetworkInterfaceIdsItem", + "AWSVpcEndpointSubnetIdsItem", + } + assert {rel["rel_type"] for rel in rels} == { + "HAS_ROUTE_TABLE_IDS", + "HAS_NETWORK_INTERFACE_IDS", + "HAS_SUBNET_IDS", + } diff --git a/api/src/backend/tasks/tests/test_attack_paths_scan.py b/api/src/backend/tasks/tests/test_attack_paths_scan.py index fef4894646..01c50c9522 100644 --- a/api/src/backend/tasks/tests/test_attack_paths_scan.py +++ b/api/src/backend/tasks/tests/test_attack_paths_scan.py @@ -23,15 +23,31 @@ from tasks.jobs.attack_paths import internet as internet_module from tasks.jobs.attack_paths import sync as sync_module from tasks.jobs.attack_paths.scan import run as attack_paths_run +SYNC_RESULT_EMPTY = { + "nodes": 0, + "child_nodes": 0, + "relationships": 0, + "structural_relationships": 0, + "item_relationships": 0, +} + @pytest.mark.django_db class TestAttackPathsRun: + @pytest.fixture(autouse=True) + def mock_graph_database_preflight(self): + with patch( + "tasks.jobs.attack_paths.scan.graph_database.verify_scan_databases_available" + ) as mock_preflight: + yield mock_preflight + # Patching with decorators as we got a `SyntaxError: too many statically nested blocks` error if we use context managers @patch("tasks.jobs.attack_paths.scan.graph_database.drop_database") @patch( "tasks.jobs.attack_paths.scan.utils.call_within_event_loop", side_effect=lambda fn, *a, **kw: fn(*a, **kw), ) + @patch("tasks.jobs.attack_paths.scan.db_utils.set_scan_migrated") @patch("tasks.jobs.attack_paths.scan.db_utils.set_graph_data_ready") @patch("tasks.jobs.attack_paths.scan.db_utils.set_provider_graph_data_ready") @patch("tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan") @@ -39,7 +55,7 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") @patch( "tasks.jobs.attack_paths.scan.sync.sync_graph", - return_value={"nodes": 0, "relationships": 0}, + return_value=SYNC_RESULT_EMPTY, ) @patch("tasks.jobs.attack_paths.scan.graph_database.drop_subgraph", return_value=0) @patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes") @@ -48,11 +64,11 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_ontology.run") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") - @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") + @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @patch( - "tasks.jobs.attack_paths.scan.graph_database.get_uri", + "tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri", return_value="bolt://neo4j", ) @patch( @@ -66,7 +82,7 @@ class TestAttackPathsRun: def test_run_success_flow( self, mock_init_provider, - mock_get_uri, + mock_get_ingest_uri, mock_create_db, mock_clear_cache, mock_cartography_indexes, @@ -83,6 +99,7 @@ class TestAttackPathsRun: mock_finish, mock_set_provider_graph_data_ready, mock_set_graph_data_ready, + mock_set_scan_migrated, mock_event_loop, mock_drop_db, tenants_fixture, @@ -159,6 +176,7 @@ class TestAttackPathsRun: target_database="tenant-db", tenant_id=str(provider.tenant_id), provider_id=str(provider.id), + provider_type="aws", ) mock_get_ingestion.assert_called_once_with(provider.provider) mock_event_loop.assert_called_once() @@ -172,9 +190,70 @@ class TestAttackPathsRun: attack_paths_scan, StateChoices.COMPLETED, ingestion_result ) mock_set_provider_graph_data_ready.assert_called_once_with( - attack_paths_scan, False + attack_paths_scan, False, "neo4j" ) mock_set_graph_data_ready.assert_called_once_with(attack_paths_scan, True) + # is_migrated is flipped to True only after the sync succeeds, so reads + # don't switch to the new catalog/sink before the graph is live. + mock_set_scan_migrated.assert_called_once_with(attack_paths_scan, True, "neo4j") + + def test_run_preflight_failure_does_not_start_scan( + self, + mock_graph_database_preflight, + tenants_fixture, + providers_fixture, + scans_fixture, + ): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan = scans_fixture[0] + scan.provider = provider + scan.save() + + attack_paths_scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.SCHEDULED, + ) + mock_graph_database_preflight.side_effect = RuntimeError("graph unavailable") + + with ( + patch( + "tasks.jobs.attack_paths.scan.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ), + patch( + "tasks.jobs.attack_paths.scan.initialize_prowler_provider", + return_value=MagicMock(_enabled_regions=["us-east-1"]), + ), + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri", + return_value="bolt://neo4j", + ), + patch( + "tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan", + return_value=attack_paths_scan, + ), + patch( + "tasks.jobs.attack_paths.scan.get_cartography_ingestion_function", + return_value=MagicMock(return_value={}), + ), + patch( + "tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan" + ) as mock_starting, + patch( + "tasks.jobs.attack_paths.scan.graph_database.create_database" + ) as mock_create_db, + ): + with pytest.raises(RuntimeError, match="graph unavailable"): + attack_paths_run(str(tenant.id), str(scan.id), "task-123") + + mock_graph_database_preflight.assert_called_once_with() + mock_starting.assert_not_called() + mock_create_db.assert_not_called() @patch( "tasks.jobs.attack_paths.scan.utils.stringify_exception", @@ -194,13 +273,13 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.internet.analysis") @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") - @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") + @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @patch( "tasks.jobs.attack_paths.scan.graph_database.get_database_name", return_value="db-scan-id", ) - @patch("tasks.jobs.attack_paths.scan.graph_database.get_uri") + @patch("tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri") @patch( "tasks.jobs.attack_paths.scan.initialize_prowler_provider", return_value=MagicMock(_enabled_regions=["us-east-1"]), @@ -212,7 +291,7 @@ class TestAttackPathsRun: def test_run_failure_marks_scan_failed( self, mock_init_provider, - mock_get_uri, + mock_get_ingest_uri, mock_get_db_name, mock_create_db, mock_cartography_indexes, @@ -293,13 +372,13 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.internet.analysis") @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") - @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") + @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @patch( "tasks.jobs.attack_paths.scan.graph_database.get_database_name", return_value="db-scan-id", ) - @patch("tasks.jobs.attack_paths.scan.graph_database.get_uri") + @patch("tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri") @patch( "tasks.jobs.attack_paths.scan.initialize_prowler_provider", return_value=MagicMock(_enabled_regions=["us-east-1"]), @@ -311,7 +390,7 @@ class TestAttackPathsRun: def test_failure_before_gate_does_not_flip_graph_data_ready_true( self, mock_init_provider, - mock_get_uri, + mock_get_ingest_uri, mock_get_db_name, mock_create_db, mock_cartography_indexes, @@ -396,13 +475,13 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.internet.analysis") @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") - @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") + @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @patch( "tasks.jobs.attack_paths.scan.graph_database.get_database_name", return_value="db-scan-id", ) - @patch("tasks.jobs.attack_paths.scan.graph_database.get_uri") + @patch("tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri") @patch( "tasks.jobs.attack_paths.scan.initialize_prowler_provider", return_value=MagicMock(_enabled_regions=["us-east-1"]), @@ -414,7 +493,7 @@ class TestAttackPathsRun: def test_run_failure_marks_scan_failed_even_when_drop_database_fails( self, mock_init_provider, - mock_get_uri, + mock_get_ingest_uri, mock_get_db_name, mock_create_db, mock_cartography_indexes, @@ -493,7 +572,7 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") @patch( "tasks.jobs.attack_paths.scan.sync.sync_graph", - return_value={"nodes": 0, "relationships": 0}, + return_value=SYNC_RESULT_EMPTY, ) @patch( "tasks.jobs.attack_paths.scan.graph_database.drop_subgraph", @@ -505,11 +584,11 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_ontology.run") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") - @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") + @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @patch( - "tasks.jobs.attack_paths.scan.graph_database.get_uri", + "tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri", return_value="bolt://neo4j", ) @patch( @@ -523,7 +602,7 @@ class TestAttackPathsRun: def test_failure_after_gate_before_drop_restores_graph_data_ready( self, mock_init_provider, - mock_get_uri, + mock_get_ingest_uri, mock_create_db, mock_clear_cache, mock_cartography_indexes, @@ -589,8 +668,8 @@ class TestAttackPathsRun: attack_paths_run(str(tenant.id), str(scan.id), "task-456") assert mock_set_provider_graph_data_ready.call_args_list == [ - call(attack_paths_scan, False), - call(attack_paths_scan, True), + call(attack_paths_scan, False, "neo4j"), + call(attack_paths_scan, True, "neo4j"), ] @patch( @@ -618,11 +697,11 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_ontology.run") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") - @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") + @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @patch( - "tasks.jobs.attack_paths.scan.graph_database.get_uri", + "tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri", return_value="bolt://neo4j", ) @patch( @@ -636,7 +715,7 @@ class TestAttackPathsRun: def test_failure_after_drop_before_sync_leaves_graph_data_ready_false( self, mock_init_provider, - mock_get_uri, + mock_get_ingest_uri, mock_create_db, mock_clear_cache, mock_cartography_indexes, @@ -703,7 +782,7 @@ class TestAttackPathsRun: # Only called with False (gate), never with True (no recovery for partial data) mock_set_provider_graph_data_ready.assert_called_once_with( - attack_paths_scan, False + attack_paths_scan, False, "neo4j" ) @patch( @@ -716,6 +795,7 @@ class TestAttackPathsRun: ) @patch("tasks.jobs.attack_paths.scan.graph_database.drop_database") @patch("tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan") + @patch("tasks.jobs.attack_paths.scan.db_utils.set_scan_migrated") @patch( "tasks.jobs.attack_paths.scan.db_utils.set_graph_data_ready", side_effect=[RuntimeError("flag failed"), None], @@ -725,7 +805,7 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") @patch( "tasks.jobs.attack_paths.scan.sync.sync_graph", - return_value={"nodes": 0, "relationships": 0}, + return_value=SYNC_RESULT_EMPTY, ) @patch("tasks.jobs.attack_paths.scan.graph_database.drop_subgraph") @patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes") @@ -734,11 +814,11 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_ontology.run") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") - @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") + @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @patch( - "tasks.jobs.attack_paths.scan.graph_database.get_uri", + "tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri", return_value="bolt://neo4j", ) @patch( @@ -752,7 +832,7 @@ class TestAttackPathsRun: def test_failure_after_sync_restores_graph_data_ready( self, mock_init_provider, - mock_get_uri, + mock_get_ingest_uri, mock_create_db, mock_clear_cache, mock_cartography_indexes, @@ -768,6 +848,7 @@ class TestAttackPathsRun: mock_update_progress, mock_set_provider_graph_data_ready, mock_set_graph_data_ready, + mock_set_scan_migrated, mock_finish, mock_drop_db, mock_event_loop, @@ -824,8 +905,11 @@ class TestAttackPathsRun: ] # set_provider_graph_data_ready only called once with False (the gate) mock_set_provider_graph_data_ready.assert_called_once_with( - attack_paths_scan, False + attack_paths_scan, False, "neo4j" ) + # is_migrated is flipped once after the sync and is not touched again by + # the failure-recovery branch + mock_set_scan_migrated.assert_called_once_with(attack_paths_scan, True, "neo4j") @patch( "tasks.jobs.attack_paths.scan.utils.stringify_exception", @@ -843,7 +927,7 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") @patch( "tasks.jobs.attack_paths.scan.sync.sync_graph", - return_value={"nodes": 0, "relationships": 0}, + return_value=SYNC_RESULT_EMPTY, ) @patch( "tasks.jobs.attack_paths.scan.graph_database.drop_subgraph", @@ -855,11 +939,11 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_ontology.run") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") - @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") + @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @patch( - "tasks.jobs.attack_paths.scan.graph_database.get_uri", + "tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri", return_value="bolt://neo4j", ) @patch( @@ -873,7 +957,7 @@ class TestAttackPathsRun: def test_recovery_failure_does_not_suppress_original_exception( self, mock_init_provider, - mock_get_uri, + mock_get_ingest_uri, mock_create_db, mock_clear_cache, mock_cartography_indexes, @@ -1116,7 +1200,7 @@ class TestFailAttackPathsScan: fail_attack_paths_scan(str(tenant.id), "nonexistent", "setup exploded") def test_fail_recovers_graph_data_ready_when_data_exists( - self, tenants_fixture, providers_fixture, scans_fixture + self, tenants_fixture, providers_fixture, scans_fixture, sink_backend_stub ): from tasks.jobs.attack_paths.db_utils import fail_attack_paths_scan @@ -1135,16 +1219,18 @@ class TestFailAttackPathsScan: state=StateChoices.EXECUTING, ) + # `recover_graph_data_ready` routes `has_provider_data` through + # `sink_module.get_backend_for_scan(scan)`. With `is_migrated=False` + # and the default `ATTACK_PATHS_SINK_DATABASE=neo4j`, the factory + # returns the active backend, which `sink_backend_stub` replaces. + sink_backend_stub.has_provider_data.return_value = True + with ( patch( "tasks.jobs.attack_paths.db_utils.retrieve_attack_paths_scan", return_value=attack_paths_scan, ), patch("tasks.jobs.attack_paths.db_utils.graph_database.drop_database"), - patch( - "tasks.jobs.attack_paths.db_utils.graph_database.has_provider_data", - return_value=True, - ), patch( "tasks.jobs.attack_paths.db_utils.set_provider_graph_data_ready" ) as mock_set_ready, @@ -1154,7 +1240,7 @@ class TestFailAttackPathsScan: mock_set_ready.assert_called_once_with(attack_paths_scan, True) def test_fail_leaves_graph_data_ready_false_when_no_data( - self, tenants_fixture, providers_fixture, scans_fixture + self, tenants_fixture, providers_fixture, scans_fixture, sink_backend_stub ): from tasks.jobs.attack_paths.db_utils import fail_attack_paths_scan @@ -1173,16 +1259,14 @@ class TestFailAttackPathsScan: state=StateChoices.EXECUTING, ) + sink_backend_stub.has_provider_data.return_value = False + with ( patch( "tasks.jobs.attack_paths.db_utils.retrieve_attack_paths_scan", return_value=attack_paths_scan, ), patch("tasks.jobs.attack_paths.db_utils.graph_database.drop_database"), - patch( - "tasks.jobs.attack_paths.db_utils.graph_database.has_provider_data", - return_value=False, - ), patch( "tasks.jobs.attack_paths.db_utils.set_provider_graph_data_ready" ) as mock_set_ready, @@ -1271,6 +1355,20 @@ class TestAttackPathsFindingsHelpers: [call(mock_session, stmt) for stmt in FINDINGS_INDEX_STATEMENTS] ) + def test_create_findings_indexes_runs_even_when_sink_is_neptune(self, settings): + # The index helpers run against the temp ingest DB, which is always + # Neo4j regardless of the configured sink. A Neptune sink must not + # suppress index creation on that DB (regression for the dropped + # in-helper sink gate). + settings.ATTACK_PATHS_SINK_DATABASE = "neptune" + mock_session = MagicMock() + with patch("tasks.jobs.attack_paths.indexes.run_write_query") as mock_run_write: + indexes_module.create_findings_indexes(mock_session) + + from tasks.jobs.attack_paths.indexes import FINDINGS_INDEX_STATEMENTS + + assert mock_run_write.call_count == len(FINDINGS_INDEX_STATEMENTS) + def test_load_findings_batches_requests(self, providers_fixture): provider = providers_fixture[0] provider.provider = Provider.ProviderChoices.AWS @@ -1802,7 +1900,13 @@ def _make_session_ctx(session, call_order=None, name=None): class TestSyncNodes: - def test_sync_nodes_adds_private_label(self): + def test_iter_sink_batches_rejects_zero_batch_size(self): + with pytest.raises( + ValueError, match="Sink batch size must be greater than zero" + ): + list(sync_module._iter_sink_batches([], batch_size=0)) + + def test_sync_nodes_passes_isolation_labels_to_sink(self): row = { "internal_id": 1, "element_id": "elem-1", @@ -1812,29 +1916,32 @@ class TestSyncNodes: mock_source_1 = MagicMock() mock_source_1.run.return_value = [row] - mock_target = MagicMock() mock_source_2 = MagicMock() mock_source_2.run.return_value = [] + sink = MagicMock() with patch( "tasks.jobs.attack_paths.sync.graph_database.get_session", side_effect=[ _make_session_ctx(mock_source_1), - _make_session_ctx(mock_target), _make_session_ctx(mock_source_2), ], ): - total = sync_module.sync_nodes( - "source-db", "target-db", "tenant-1", "prov-1" + result = sync_module.sync_nodes( + "source-db", "target-db", "tenant-1", "prov-1", sink, [] ) - assert total == 1 - query = mock_target.run.call_args.args[0] - assert "_ProviderResource" in query - assert "_Tenant_tenant1" in query - assert "_Provider_prov1" in query + assert result["parents"] == 1 + sink.write_nodes.assert_called_once() + target_db, labels, batch = sink.write_nodes.call_args.args + assert target_db == "target-db" + assert "_ProviderResource" in labels + assert "_Tenant_tenant1" in labels + assert "_Provider_prov1" in labels + assert batch[0]["provider_element_id"] == "prov-1:elem-1" + assert batch[0]["props"] == {"key": "value"} - def test_sync_nodes_source_closes_before_target_opens(self): + def test_sync_nodes_writes_after_source_session_closes(self): row = { "internal_id": 1, "element_id": "elem-1", @@ -1846,21 +1953,23 @@ class TestSyncNodes: src_1 = MagicMock() src_1.run.return_value = [row] - tgt = MagicMock() src_2 = MagicMock() src_2.run.return_value = [] + sink = MagicMock() + sink.write_nodes.side_effect = lambda *_a, **_kw: call_order.append( + "sink:write" + ) with patch( "tasks.jobs.attack_paths.sync.graph_database.get_session", side_effect=[ _make_session_ctx(src_1, call_order, "source1"), - _make_session_ctx(tgt, call_order, "target"), _make_session_ctx(src_2, call_order, "source2"), ], ): - sync_module.sync_nodes("src-db", "tgt-db", "t-1", "p-1") + sync_module.sync_nodes("src-db", "tgt-db", "t-1", "p-1", sink, []) - assert call_order.index("source1:exit") < call_order.index("target:enter") + assert call_order.index("source1:exit") < call_order.index("sink:write") def test_sync_nodes_pagination_with_batch_size_1(self): row_a = { @@ -1882,44 +1991,89 @@ class TestSyncNodes: src_2.run.return_value = [row_b] src_3 = MagicMock() src_3.run.return_value = [] - tgt_1 = MagicMock() - tgt_2 = MagicMock() + sink = MagicMock() with ( patch( "tasks.jobs.attack_paths.sync.graph_database.get_session", side_effect=[ _make_session_ctx(src_1), - _make_session_ctx(tgt_1), _make_session_ctx(src_2), - _make_session_ctx(tgt_2), _make_session_ctx(src_3), ], ), patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 1), ): - total = sync_module.sync_nodes("src", "tgt", "t-1", "p-1") + result = sync_module.sync_nodes("src", "tgt", "t-1", "p-1", sink, []) - assert total == 2 + assert result["parents"] == 2 + assert sink.write_nodes.call_count == 2 assert src_1.run.call_args.args[1]["last_id"] == -1 assert src_2.run.call_args.args[1]["last_id"] == 1 + def test_sync_nodes_chunks_expanded_list_rows_before_sink_write(self): + row = { + "internal_id": 1, + "element_id": "elem-1", + "labels": ["SomeLabel"], + "props": {"values": ["a", "b", "c", "d", "e"]}, + } + normalized_lists = [ + sync_module.NormalizedList( + "SomeLabel", + "values", + "SomeLabelValuesItem", + "HAS_VALUES", + ) + ] + + src_1 = MagicMock() + src_1.run.return_value = [row] + src_2 = MagicMock() + src_2.run.return_value = [] + sink = MagicMock() + + with ( + patch( + "tasks.jobs.attack_paths.sync.graph_database.get_session", + side_effect=[ + _make_session_ctx(src_1), + _make_session_ctx(src_2), + ], + ), + patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 2), + ): + result = sync_module.sync_nodes( + "src", "tgt", "t-1", "p-1", sink, normalized_lists + ) + + assert result == {"parents": 1, "children": 5, "parent_child_rels": 5} + assert [ + len(call_args.args[2]) for call_args in sink.write_nodes.call_args_list[1:] + ] == [2, 2, 1] + assert [ + len(call_args.args[3]) + for call_args in sink.write_relationships.call_args_list + ] == [2, 2, 1] + def test_sync_nodes_empty_source_returns_zero(self): src = MagicMock() src.run.return_value = [] + sink = MagicMock() with patch( "tasks.jobs.attack_paths.sync.graph_database.get_session", side_effect=[_make_session_ctx(src)], ) as mock_get_session: - total = sync_module.sync_nodes("src", "tgt", "t-1", "p-1") + result = sync_module.sync_nodes("src", "tgt", "t-1", "p-1", sink, []) - assert total == 0 + assert result["parents"] == 0 assert mock_get_session.call_count == 1 + sink.write_nodes.assert_not_called() class TestSyncRelationships: - def test_sync_relationships_source_closes_before_target_opens(self): + def test_sync_relationships_writes_after_source_session_closes(self): row = { "internal_id": 1, "rel_type": "HAS", @@ -1932,21 +2086,23 @@ class TestSyncRelationships: src_1 = MagicMock() src_1.run.return_value = [row] - tgt = MagicMock() src_2 = MagicMock() src_2.run.return_value = [] + sink = MagicMock() + sink.write_relationships.side_effect = lambda *_a, **_kw: call_order.append( + "sink:write" + ) with patch( "tasks.jobs.attack_paths.sync.graph_database.get_session", side_effect=[ _make_session_ctx(src_1, call_order, "source1"), - _make_session_ctx(tgt, call_order, "target"), _make_session_ctx(src_2, call_order, "source2"), ], ): - sync_module.sync_relationships("src", "tgt", "p-1") + sync_module.sync_relationships("src", "tgt", "p-1", sink) - assert call_order.index("source1:exit") < call_order.index("target:enter") + assert call_order.index("source1:exit") < call_order.index("sink:write") def test_sync_relationships_pagination_with_batch_size_1(self): row_a = { @@ -1970,40 +2126,76 @@ class TestSyncRelationships: src_2.run.return_value = [row_b] src_3 = MagicMock() src_3.run.return_value = [] - tgt_1 = MagicMock() - tgt_2 = MagicMock() + sink = MagicMock() with ( patch( "tasks.jobs.attack_paths.sync.graph_database.get_session", side_effect=[ _make_session_ctx(src_1), - _make_session_ctx(tgt_1), _make_session_ctx(src_2), - _make_session_ctx(tgt_2), _make_session_ctx(src_3), ], ), patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 1), ): - total = sync_module.sync_relationships("src", "tgt", "p-1") + total = sync_module.sync_relationships("src", "tgt", "p-1", sink) assert total == 2 + assert sink.write_relationships.call_count == 2 assert src_1.run.call_args.args[1]["last_id"] == -1 assert src_2.run.call_args.args[1]["last_id"] == 1 + def test_sync_relationships_chunks_grouped_rows_before_sink_write(self): + rows = [ + { + "internal_id": idx, + "rel_type": "HAS", + "start_element_id": f"s-{idx}", + "end_element_id": f"e-{idx}", + "props": {}, + } + for idx in range(1, 6) + ] + + src_1 = MagicMock() + src_1.run.return_value = rows + src_2 = MagicMock() + src_2.run.return_value = [] + sink = MagicMock() + + with ( + patch( + "tasks.jobs.attack_paths.sync.graph_database.get_session", + side_effect=[ + _make_session_ctx(src_1), + _make_session_ctx(src_2), + ], + ), + patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 2), + ): + total = sync_module.sync_relationships("src", "tgt", "p-1", sink) + + assert total == 5 + assert [ + len(call_args.args[3]) + for call_args in sink.write_relationships.call_args_list + ] == [2, 2, 1] + def test_sync_relationships_empty_source_returns_zero(self): src = MagicMock() src.run.return_value = [] + sink = MagicMock() with patch( "tasks.jobs.attack_paths.sync.graph_database.get_session", side_effect=[_make_session_ctx(src)], ) as mock_get_session: - total = sync_module.sync_relationships("src", "tgt", "p-1") + total = sync_module.sync_relationships("src", "tgt", "p-1", sink) assert total == 0 assert mock_get_session.call_count == 1 + sink.write_relationships.assert_not_called() class TestInternetAnalysis: @@ -2075,6 +2267,8 @@ class TestAttackPathsDbUtilsGraphDataReady: assert attack_paths_scan is not None assert attack_paths_scan.graph_data_ready is False + assert attack_paths_scan.is_migrated is False + assert attack_paths_scan.sink_backend == "neo4j" def test_create_attack_paths_scan_inherits_true_from_previous( self, tenants_fixture, providers_fixture, scans_fixture @@ -2095,6 +2289,8 @@ class TestAttackPathsDbUtilsGraphDataReady: scan=scan, state=StateChoices.COMPLETED, graph_data_ready=True, + is_migrated=True, + sink_backend="neptune", ) new_scan = Scan.objects.create( @@ -2115,6 +2311,109 @@ class TestAttackPathsDbUtilsGraphDataReady: assert attack_paths_scan is not None assert attack_paths_scan.graph_data_ready is True + # is_migrated tracks the data being served: inherited from the ready scan + assert attack_paths_scan.is_migrated is True + assert attack_paths_scan.sink_backend == "neptune" + + def test_create_attack_paths_scan_prefers_active_sink_ready_scan( + self, tenants_fixture, providers_fixture, scans_fixture, settings + ): + from tasks.jobs.attack_paths.db_utils import create_attack_paths_scan + + settings.ATTACK_PATHS_SINK_DATABASE = "neo4j" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan = scans_fixture[0] + scan.provider = provider + scan.save() + + AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.COMPLETED, + graph_data_ready=True, + is_migrated=False, + sink_backend="neo4j", + ) + AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.COMPLETED, + graph_data_ready=True, + is_migrated=True, + sink_backend="neptune", + ) + + new_scan = Scan.objects.create( + name="New Scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.AVAILABLE, + tenant_id=tenant.id, + ) + + with patch( + "tasks.jobs.attack_paths.db_utils.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ): + attack_paths_scan = create_attack_paths_scan( + str(tenant.id), str(new_scan.id), provider.id + ) + + assert attack_paths_scan is not None + assert attack_paths_scan.graph_data_ready is True + assert attack_paths_scan.is_migrated is False + assert attack_paths_scan.sink_backend == "neo4j" + + def test_create_attack_paths_scan_inherits_is_migrated_false_from_legacy_ready( + self, tenants_fixture, providers_fixture, scans_fixture + ): + from tasks.jobs.attack_paths.db_utils import create_attack_paths_scan + + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan = scans_fixture[0] + scan.provider = provider + scan.save() + + # Previous scan is ready but pre-cutover (legacy Neo4j graph shape) + AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.COMPLETED, + graph_data_ready=True, + is_migrated=False, + sink_backend="neo4j", + ) + + new_scan = Scan.objects.create( + name="New Scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.AVAILABLE, + tenant_id=tenant.id, + ) + + with patch( + "tasks.jobs.attack_paths.db_utils.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ): + attack_paths_scan = create_attack_paths_scan( + str(tenant.id), str(new_scan.id), provider.id + ) + + assert attack_paths_scan is not None + assert attack_paths_scan.graph_data_ready is True + # Reads stay on the legacy catalog/backend until this scan's own sync + assert attack_paths_scan.is_migrated is False + assert attack_paths_scan.sink_backend == "neo4j" def test_create_attack_paths_scan_inherits_false_when_no_previous_ready( self, tenants_fixture, providers_fixture, scans_fixture @@ -2135,6 +2434,7 @@ class TestAttackPathsDbUtilsGraphDataReady: scan=scan, state=StateChoices.FAILED, graph_data_ready=False, + sink_backend="neptune", ) new_scan = Scan.objects.create( @@ -2155,6 +2455,8 @@ class TestAttackPathsDbUtilsGraphDataReady: assert attack_paths_scan is not None assert attack_paths_scan.graph_data_ready is False + assert attack_paths_scan.is_migrated is False + assert attack_paths_scan.sink_backend == "neo4j" def test_set_graph_data_ready_updates_field( self, tenants_fixture, providers_fixture, scans_fixture @@ -2261,7 +2563,7 @@ class TestAttackPathsDbUtilsGraphDataReady: assert attack_paths_scan.state == StateChoices.FAILED assert attack_paths_scan.graph_data_ready is True - def test_set_provider_graph_data_ready_updates_all_scans_for_provider( + def test_set_provider_graph_data_ready_updates_all_scans_for_provider_sink( self, tenants_fixture, providers_fixture, scans_fixture ): from tasks.jobs.attack_paths.db_utils import set_provider_graph_data_ready @@ -2289,6 +2591,7 @@ class TestAttackPathsDbUtilsGraphDataReady: scan=scan_a, state=StateChoices.COMPLETED, graph_data_ready=True, + sink_backend="neptune", ) new_ap_scan = AttackPathsScan.objects.create( tenant_id=tenant.id, @@ -2296,6 +2599,7 @@ class TestAttackPathsDbUtilsGraphDataReady: scan=scan_b, state=StateChoices.EXECUTING, graph_data_ready=True, + sink_backend="neptune", ) with patch( @@ -2309,6 +2613,48 @@ class TestAttackPathsDbUtilsGraphDataReady: assert old_ap_scan.graph_data_ready is False assert new_ap_scan.graph_data_ready is False + def test_set_provider_graph_data_ready_preserves_other_sink_scans( + self, tenants_fixture, providers_fixture, scans_fixture + ): + from tasks.jobs.attack_paths.db_utils import set_provider_graph_data_ready + + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + scan = scans_fixture[0] + scan.provider = provider + scan.save() + + legacy_scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.COMPLETED, + graph_data_ready=True, + sink_backend="neo4j", + ) + neptune_scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.EXECUTING, + graph_data_ready=True, + sink_backend="neptune", + ) + + with patch( + "tasks.jobs.attack_paths.db_utils.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ): + set_provider_graph_data_ready(neptune_scan, False) + + legacy_scan.refresh_from_db() + neptune_scan.refresh_from_db() + assert legacy_scan.graph_data_ready is True + assert neptune_scan.graph_data_ready is False + def test_set_provider_graph_data_ready_does_not_affect_other_providers( self, tenants_fixture, providers_fixture, scans_fixture ): @@ -2871,3 +3217,57 @@ class TestCleanupStaleAttackPathsScans: ap_scan.refresh_from_db() assert ap_scan.state == StateChoices.SCHEDULED mock_revoke.assert_not_called() + + +class TestNormalizeSinkProperties: + """Coerce Cartography-emitted property values into sink-portable primitives. + + Lists become comma-strings, dicts become JSON strings, temporals become + ISO strings, spatials become their stringified form. The same coercion + runs regardless of the active sink so queries are portable. + """ + + @pytest.mark.parametrize( + "raw, expected", + [ + ( + {"a": "x", "b": 1, "c": 1.5, "d": True, "e": None}, + {"a": "x", "b": 1, "c": 1.5, "d": True, "e": None}, + ), + ( + {"actions": ["s3:GetObject", "s3:PutObject"], "tags": []}, + {"actions": "s3:GetObject,s3:PutObject", "tags": ""}, + ), + ( + {"condition": {"StringEquals": {"aws:SourceAccount": "123456789012"}}}, + { + "condition": '{"StringEquals": {"aws:SourceAccount": "123456789012"}}' + }, + ), + ], + ) + def test_primitive_list_and_dict_branches(self, raw, expected): + sync_module._normalize_sink_properties(raw, labels=None) + assert raw == expected + + def test_temporal_and_spatial_become_strings(self): + class FakeDateTime: + def iso_format(self) -> str: + return "2026-05-13T10:00:00+00:00" + + class FakeSpatialPoint: + def __str__(self) -> str: + return "POINT(1.0 2.0)" + + # The spatial branch is detected by module prefix, not by base class. + FakeSpatialPoint.__module__ = "neo4j.spatial.fake" + + props = { + "created_at": FakeDateTime(), + "location": FakeSpatialPoint(), + } + sync_module._normalize_sink_properties(props, labels=None) + assert props == { + "created_at": "2026-05-13T10:00:00+00:00", + "location": "POINT(1.0 2.0)", + } diff --git a/api/src/backend/tasks/tests/test_deletion.py b/api/src/backend/tasks/tests/test_deletion.py index 1124334861..c6e2cd408c 100644 --- a/api/src/backend/tasks/tests/test_deletion.py +++ b/api/src/backend/tasks/tests/test_deletion.py @@ -1,4 +1,4 @@ -from unittest.mock import call, patch +from unittest.mock import MagicMock, call, patch import pytest from api.attack_paths import database as graph_database @@ -60,10 +60,12 @@ class TestDeleteProvider: aps1 = create_attack_paths_scan(instance) aps2 = create_attack_paths_scan(instance) + backend = MagicMock() with ( patch( - "tasks.jobs.deletion.graph_database.drop_subgraph", + "tasks.jobs.deletion.sink_module.get_backend_for_name", + return_value=backend, ), patch( "tasks.jobs.deletion.graph_database.drop_database", @@ -72,12 +74,55 @@ class TestDeleteProvider: result = delete_provider(tenant_id, instance.id) assert result + backend.drop_subgraph.assert_called_once_with( + graph_database.get_database_name(tenant_id), str(instance.id) + ) expected_tmp_calls = [ call(f"db-tmp-scan-{str(aps1.id).lower()}"), call(f"db-tmp-scan-{str(aps2.id).lower()}"), ] mock_drop_database.assert_has_calls(expected_tmp_calls, any_order=True) + def test_delete_provider_drops_graph_data_from_all_recorded_sinks( + self, providers_fixture, create_attack_paths_scan + ): + instance = providers_fixture[0] + tenant_id = str(instance.tenant_id) + create_attack_paths_scan(instance, sink_backend="neo4j") + create_attack_paths_scan(instance, sink_backend="neptune") + neo4j_backend = MagicMock() + neptune_backend = MagicMock() + + def get_backend_for_name(name): + return { + "neo4j": neo4j_backend, + "neptune": neptune_backend, + }[name] + + with ( + patch( + "tasks.jobs.deletion.graph_database.get_database_name", + return_value="tenant-db", + ), + patch( + "tasks.jobs.deletion.sink_module.get_backend_for_name", + side_effect=get_backend_for_name, + ) as mock_get_backend_for_name, + patch("tasks.jobs.deletion.graph_database.drop_database"), + ): + result = delete_provider(tenant_id, instance.id) + + assert result + mock_get_backend_for_name.assert_has_calls( + [call("neo4j"), call("neptune")], any_order=True + ) + neo4j_backend.drop_subgraph.assert_called_once_with( + "tenant-db", str(instance.id) + ) + neptune_backend.drop_subgraph.assert_called_once_with( + "tenant-db", str(instance.id) + ) + def test_delete_provider_continues_when_temp_db_drop_fails( self, providers_fixture, create_attack_paths_scan ): @@ -85,10 +130,12 @@ class TestDeleteProvider: tenant_id = str(instance.tenant_id) create_attack_paths_scan(instance) + backend = MagicMock() with ( patch( - "tasks.jobs.deletion.graph_database.drop_subgraph", + "tasks.jobs.deletion.sink_module.get_backend_for_name", + return_value=backend, ), patch( "tasks.jobs.deletion.graph_database.drop_database", diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index 17b3b65fc4..2d251b247f 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -9,7 +9,7 @@ from unittest.mock import MagicMock, patch import pytest from api.db_router import MainRouter -from api.exceptions import ProviderConnectionError +from api.exceptions import ProviderConnectionError, ProviderDeletedException from api.models import ( Finding, MuteRule, @@ -262,6 +262,75 @@ class TestPerformScan: assert provider.connected is False assert isinstance(provider.connection_last_checked_at, datetime) + def test_perform_prowler_scan_provider_deleted_during_progress_update( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + provider_id = str(provider.id) + + def scan_results(): + Provider.objects.filter(pk=provider_id).delete() + yield 50, [] + + with ( + patch( + "tasks.jobs.scan.initialize_prowler_provider", + return_value=MagicMock(), + ), + patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class, + patch("tasks.jobs.scan.logger.error") as mock_logger_error, + ): + mock_prowler_scan_instance = MagicMock() + mock_prowler_scan_instance.scan.return_value = scan_results() + mock_prowler_scan_class.return_value = mock_prowler_scan_instance + + with pytest.raises(ProviderDeletedException): + perform_prowler_scan(tenant_id, scan_id, provider_id, []) + + mock_logger_error.assert_not_called() + assert not Scan.objects.filter(pk=scan_id).exists() + + def test_perform_prowler_scan_sets_final_progress_when_progress_updates_are_throttled( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + provider_id = str(provider.id) + + with ( + patch( + "tasks.jobs.scan.initialize_prowler_provider", + return_value=MagicMock(), + ), + patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class, + patch("tasks.jobs.scan.PROGRESS_THROTTLE_DELTA", 200), + patch("tasks.jobs.scan.PROGRESS_THROTTLE_SECONDS", 3600), + ): + mock_prowler_scan_instance = MagicMock() + mock_prowler_scan_instance.scan.return_value = [(99, []), (100, [])] + mock_prowler_scan_class.return_value = mock_prowler_scan_instance + + perform_prowler_scan(tenant_id, scan_id, provider_id, []) + + scan.refresh_from_db() + assert scan.state == StateChoices.COMPLETED + assert scan.progress == 100 + @pytest.mark.parametrize( "last_status, new_status, expected_delta", [ diff --git a/api/uv.lock b/api/uv.lock index 06b1968c9a..020f3bde4f 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -110,7 +110,7 @@ constraints = [ { name = "blinker", specifier = "==1.9.0" }, { name = "boto3", specifier = "==1.40.61" }, { name = "botocore", specifier = "==1.40.61" }, - { name = "cartography", specifier = "==0.135.0" }, + { name = "cartography", specifier = "==0.138.1" }, { name = "celery", specifier = "==5.6.2" }, { name = "certifi", specifier = "==2026.1.4" }, { name = "cffi", specifier = "==2.0.0" }, @@ -135,7 +135,6 @@ constraints = [ { name = "debugpy", specifier = "==1.8.20" }, { name = "decorator", specifier = "==5.2.1" }, { name = "defusedxml", specifier = "==0.7.1" }, - { name = "detect-secrets", specifier = "==1.5.0" }, { name = "dill", specifier = "==0.4.1" }, { name = "distro", specifier = "==1.9.0" }, { name = "dj-rest-auth", specifier = "==7.0.1" }, @@ -218,6 +217,7 @@ constraints = [ { name = "jsonschema", specifier = "==4.23.0" }, { name = "jsonschema-specifications", specifier = "==2025.9.1" }, { name = "keystoneauth1", specifier = "==5.13.0" }, + { name = "kingfisher-bin", specifier = "==1.104.0" }, { name = "kiwisolver", specifier = "==1.4.9" }, { name = "knack", specifier = "==0.11.0" }, { name = "kombu", specifier = "==5.6.2" }, @@ -364,7 +364,7 @@ constraints = [ { name = "wcwidth", specifier = "==0.5.3" }, { name = "websocket-client", specifier = "==1.9.0" }, { name = "werkzeug", specifier = "==3.1.7" }, - { name = "workos", specifier = "==6.0.4" }, + { name = "workos", specifier = "==6.0.8" }, { name = "wrapt", specifier = "==1.17.3" }, { name = "xlsxwriter", specifier = "==3.2.9" }, { name = "xmlsec", specifier = "==1.3.17" }, @@ -376,6 +376,7 @@ constraints = [ { name = "zstd", specifier = "==1.5.7.3" }, ] overrides = [ + { name = "azure-mgmt-containerservice", specifier = "==34.1.0" }, { name = "dulwich", specifier = "==1.2.5" }, { name = "microsoft-kiota-abstractions", specifier = "==1.9.9" }, { name = "okta", specifier = "==3.4.2" }, @@ -1407,6 +1408,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3d/66/0d8ae9ca4d75e57746026a1f9a10a7e25029511c128cf20166fce516bda9/azure_mgmt_logic-10.0.0-py3-none-any.whl", hash = "sha256:525c78afedf3edb35eb0a16152c8beba89769ee1bc6af01bcdc42842a551e443", size = 235433, upload-time = "2022-06-13T01:38:27.333Z" }, ] +[[package]] +name = "azure-mgmt-managementgroups" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/73/ac5e064ed7343e1b3172f32f09be3efca906087218d3046b5038f2f394ed/azure_mgmt_managementgroups-1.1.0.tar.gz", hash = "sha256:e6199baf118890ba2bda35dda83a88861c0b1bbef126311b20ec12eed9681951", size = 60101, upload-time = "2026-02-13T03:45:45.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/bc/993158de03cc0a49f2cf8192615ffedbc508c417cb3522e88f6652b714cc/azure_mgmt_managementgroups-1.1.0-py3-none-any.whl", hash = "sha256:140934589559ef6afcac6f1d24f995588a1965aaa89d47851c1cc639fafb1942", size = 83586, upload-time = "2026-02-13T03:45:46.836Z" }, +] + [[package]] name = "azure-mgmt-monitor" version = "6.0.2" @@ -1726,7 +1741,7 @@ wheels = [ [[package]] name = "cartography" -version = "0.135.0" +version = "0.138.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "adal" }, @@ -1746,6 +1761,7 @@ dependencies = [ { name = "azure-mgmt-eventhub" }, { name = "azure-mgmt-keyvault" }, { name = "azure-mgmt-logic" }, + { name = "azure-mgmt-managementgroups" }, { name = "azure-mgmt-monitor" }, { name = "azure-mgmt-network" }, { name = "azure-mgmt-resource" }, @@ -1754,6 +1770,7 @@ dependencies = [ { name = "azure-mgmt-storage" }, { name = "azure-mgmt-synapse" }, { name = "azure-mgmt-web" }, + { name = "azure-storage-blob" }, { name = "azure-synapse-artifacts" }, { name = "backoff" }, { name = "boto3" }, @@ -1765,8 +1782,12 @@ dependencies = [ { name = "duo-client" }, { name = "google-api-python-client" }, { name = "google-auth" }, + { name = "google-cloud-aiplatform" }, + { name = "google-cloud-artifact-registry" }, { name = "google-cloud-asset" }, { name = "google-cloud-resource-manager" }, + { name = "google-cloud-run" }, + { name = "google-cloud-storage" }, { name = "httpx" }, { name = "kubernetes" }, { name = "marshmallow" }, @@ -1792,9 +1813,9 @@ dependencies = [ { name = "workos" }, { name = "xmltodict" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/47/606851d2403a983b63813b9e95427a5dd896e49bc5a501868c041262e9a5/cartography-0.135.0.tar.gz", hash = "sha256:3f500cd22c3b392d00e8b49f62acc95fd4dcd559ce514aafe2eb8101133c7a49", size = 9106458, upload-time = "2026-04-10T16:25:34.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/cd/0eb6a5a3c89cc179801d902ade9719af1a583c516c00f50d72b8207db1eb/cartography-0.138.1.tar.gz", hash = "sha256:356e946a0bcac899cba293d57803c71bd35fdeabe623f5f67d9405d7a643af9f", size = 9756966, upload-time = "2026-06-19T22:11:32.411Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/e1/99a26b3e662202be77961aba73338e1448623490710b81783e53a4bbef15/cartography-0.135.0-py3-none-any.whl", hash = "sha256:c62c32a6917b8f23a8b98fe2b6c7c4a918b50f55918482966c4dae1cf5f538e1", size = 1590545, upload-time = "2026-04-10T16:25:37.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/15/4447ec968825b2a19cba26ecb74964208aa3f941d9181a7782572e30b43d/cartography-0.138.1-py3-none-any.whl", hash = "sha256:88ec0898ea1a1b3f4653be9a3e7e61144f5cee20384b9040e92039617d39f029", size = 2014725, upload-time = "2026-06-19T22:11:29.886Z" }, ] [[package]] @@ -2224,16 +2245,15 @@ wheels = [ ] [[package]] -name = "detect-secrets" -version = "1.5.0" +name = "deprecated" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyyaml" }, - { name = "requests" }, + { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/67/382a863fff94eae5a0cf05542179169a1c49a4c8784a9480621e2066ca7d/detect_secrets-1.5.0.tar.gz", hash = "sha256:6bb46dcc553c10df51475641bb30fd69d25645cc12339e46c824c1e0c388898a", size = 97351, upload-time = "2024-05-06T17:46:19.721Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/5e/4f5fe4b89fde1dc3ed0eb51bd4ce4c0bca406246673d370ea2ad0c58d747/detect_secrets-1.5.0-py3-none-any.whl", hash = "sha256:e24e7b9b5a35048c313e983f76c4bd09dad89f045ff059e354f9943bf45aa060", size = 120341, upload-time = "2024-05-06T17:46:16.628Z" }, + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] [[package]] @@ -2511,6 +2531,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "dogpile-cache" version = "1.5.0" @@ -2851,6 +2880,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, ] +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + [[package]] name = "google-auth-httplib2" version = "0.2.0" @@ -2877,6 +2911,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/94/24b010493660dd55e2d9769ae7ef44164aebd7e1f4a9266cf9459affd687/google_cloud_access_context_manager-0.3.0-py3-none-any.whl", hash = "sha256:5d15ad51547f06c281e35f16b4ffcb3e98bb2d898b01470f88b94edfb2eeb0a3", size = 58852, upload-time = "2025-10-17T02:30:33.768Z" }, ] +[[package]] +name = "google-cloud-aiplatform" +version = "1.153.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docstring-parser" }, + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "google-cloud-bigquery" }, + { name = "google-cloud-resource-manager" }, + { name = "google-cloud-storage" }, + { name = "google-genai" }, + { name = "packaging" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/97/1779e66ab845550bc602364311ea093ba156cb805a1c31b7c4d6f25b5863/google_cloud_aiplatform-1.153.1.tar.gz", hash = "sha256:445b6c683d5c630f174d81ae1f69f7da9e27e4d4ec5b70c5fe96de5c1247cfbc", size = 11011349, upload-time = "2026-05-15T06:34:14.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/01/8a1900e7a742ed480e6037ac4f6541466cb981d81bd4cbd34a9d46204ea1/google_cloud_aiplatform-1.153.1-py2.py3-none-any.whl", hash = "sha256:033fa1595a7e8ed1d97066e261e630f38fbc60e10c98c6487cf228fe9c7ec151", size = 9170782, upload-time = "2026-05-15T06:34:10.887Z" }, +] + +[[package]] +name = "google-cloud-artifact-registry" +version = "1.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/2b/24e6956789bc1244efb18143aa4f124e03d870228e5bfd065c04d38a4d6b/google_cloud_artifact_registry-1.21.0.tar.gz", hash = "sha256:546e51eb5d463a6e5c668be6727d14f8ec82bc798031398006b2213d703e184c", size = 315219, upload-time = "2026-03-30T22:50:38.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/8c/a5c68031728f38d3306bad5ac10c0ca670cbdf414db308ddefa2c47f2b34/google_cloud_artifact_registry-1.21.0-py3-none-any.whl", hash = "sha256:a07079035438fd0f2e7264d4318b388650495f011db575405c18c9881449025c", size = 250544, upload-time = "2026-03-30T22:48:49.345Z" }, +] + [[package]] name = "google-cloud-asset" version = "4.2.0" @@ -2897,6 +2971,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/88/9a43fae1d2fed94d7f5f46b6f4c44bd15e5ea0e8657632108b5ec5f53d9d/google_cloud_asset-4.2.0-py3-none-any.whl", hash = "sha256:fd7ea04c64948a4779790343204cd5b41d4772d6ab1d05a9125e28a637ac0862", size = 282707, upload-time = "2026-01-09T14:53:03.081Z" }, ] +[[package]] +name = "google-cloud-bigquery" +version = "3.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "google-resumable-media" }, + { name = "packaging" }, + { name = "python-dateutil" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/13/6515c7aab55a4a0cf708ffd309fb9af5bab54c13e32dc22c5acd6497193c/google_cloud_bigquery-3.41.0.tar.gz", hash = "sha256:2217e488b47ed576360c9b2cc07d59d883a54b83167c0ef37f915c26b01a06fe", size = 513434, upload-time = "2026-03-30T22:50:55.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/33/1d3902efadef9194566d499d61507e1f038454e0b55499d2d7f8ab2a4fee/google_cloud_bigquery-3.41.0-py3-none-any.whl", hash = "sha256:2a5b5a737b401cbd824a6e5eac7554100b878668d908e6548836b5d8aaa4dcaa", size = 262343, upload-time = "2026-03-30T22:48:45.444Z" }, +] + +[[package]] +name = "google-cloud-core" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/dd/1eef226e470369b26824a505c34482c0b493bc35fe8e0c6b003b5feca21a/google_cloud_core-2.6.0.tar.gz", hash = "sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83", size = 36001, upload-time = "2026-05-07T08:04:04.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/4a/98da8930ab109c73d9a5d13782a9ebb81ea8c111f6d534a567b71d23e52b/google_cloud_core-2.6.0-py3-none-any.whl", hash = "sha256:6d63ac8e5eca6d9e4319d0a1e2265fadcd7f1049904378caecfa01cf52dd869e", size = 29390, upload-time = "2026-05-07T08:02:34.672Z" }, +] + [[package]] name = "google-cloud-org-policy" version = "1.16.0" @@ -2946,6 +3051,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/ff/4b28bcc791d9d7e4ac8fea00fbd90ccb236afda56746a3b4564d2ae45df3/google_cloud_resource_manager-1.16.0-py3-none-any.whl", hash = "sha256:fb9a2ad2b5053c508e1c407ac31abfd1a22e91c32876c1892830724195819a28", size = 400218, upload-time = "2026-01-15T13:02:47.378Z" }, ] +[[package]] +name = "google-cloud-run" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/89/dcaf0dc97e39b41e446456ceb60657ab025de79cfccd39cbd739d1a9849e/google_cloud_run-0.16.0.tar.gz", hash = "sha256:d52cf4e6ad3702ae48caccf6abcab543afee6f61c2a6ec753cc62a31e5b629f1", size = 514452, upload-time = "2026-03-26T22:17:05.589Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/c7/46153dc13713b5e4276d86f28ff4563332f9e4bae5ebc83abc5bfd994801/google_cloud_run-0.16.0-py3-none-any.whl", hash = "sha256:d7d2dd7307130fde2a0ce27e96d580dd23b7b2d973b6484b94d902e6b2618860", size = 459112, upload-time = "2026-03-26T22:16:00.018Z" }, +] + +[[package]] +name = "google-cloud-storage" +version = "3.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "google-crc32c" }, + { name = "google-resumable-media" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/47/205eb8e9a1739b5345843e5a425775cbdc472cc38e7eda082ba5b8d02450/google_cloud_storage-3.10.1.tar.gz", hash = "sha256:97db9aa4460727982040edd2bd13ff3d5e2260b5331ad22895802da1fc2a5286", size = 17309950, upload-time = "2026-03-23T09:35:23.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/ff/ca9ab2417fa913d75aae38bf40bf856bb2749a604b2e0f701b37cfcd23cc/google_cloud_storage-3.10.1-py3-none-any.whl", hash = "sha256:a72f656759b7b99bda700f901adcb3425a828d4a29f911bc26b3ea79c5b1217f", size = 324453, upload-time = "2026-03-23T09:35:21.368Z" }, +] + +[[package]] +name = "google-crc32c" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, + { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, +] + +[[package]] +name = "google-genai" +version = "1.68.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/2c/f059982dbcb658cc535c81bbcbe7e2c040d675f4b563b03cdb01018a4bc3/google_genai-1.68.0.tar.gz", hash = "sha256:ac30c0b8bc630f9372993a97e4a11dae0e36f2e10d7c55eacdca95a9fa14ca96", size = 511285, upload-time = "2026-03-18T01:03:18.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/de/7d3ee9c94b74c3578ea4f88d45e8de9405902f857932334d81e89bce3dfa/google_genai-1.68.0-py3-none-any.whl", hash = "sha256:a1bc9919c0e2ea2907d1e319b65471d3d6d58c54822039a249fe1323e4178d15", size = 750912, upload-time = "2026-03-18T01:03:15.983Z" }, +] + +[[package]] +name = "google-resumable-media" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-crc32c" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/4b/0b235beccc310d0a48adbc7246b719d173cca6c88c572dfa4b090e39143c/google_resumable_media-2.9.0.tar.gz", hash = "sha256:f7cfb224846a9dd444d125115dfbe8ef02a2b893e78f087762fe716a255a734b", size = 2164534, upload-time = "2026-05-07T08:04:44.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/73/3518e63deb1667c5409a4579e28daf5e84479a87a72c547e0487f7883dcd/google_resumable_media-2.9.0-py3-none-any.whl", hash = "sha256:c8901e88e389af8bed64d9696c74d8bad961865eb2236e13e0bfca9bb0a65ca3", size = 81507, upload-time = "2026-05-07T08:03:23.809Z" }, +] + [[package]] name = "googleapis-common-protos" version = "1.72.0" @@ -3420,6 +3612,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/99/76476a1057b349c860bae72e45d6ef438feb877c84ee7d565faf464e54c3/keystoneauth1-5.13.0-py3-none-any.whl", hash = "sha256:5ab81412eb0923ceb9c602cc3decce514b399523cb83d16b409ed3b0f9b03d41", size = 343585, upload-time = "2026-01-19T10:47:00.762Z" }, ] +[[package]] +name = "kingfisher-bin" +version = "1.104.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/2b/324212f1baf482a7d4b66a2edf33073336735b67bb6b04a38d18fd9e67fb/kingfisher_bin-1.104.0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:8e3840e67004a971fef80aba240ee5c3c5f7a3a343a6d1083a2751aaf866d5d3", size = 14057606, upload-time = "2026-06-22T03:03:01.419Z" }, + { url = "https://files.pythonhosted.org/packages/21/0a/cbf964da5102657cb9be4a59db7c9f7807ef88f9419673b7486daba785d3/kingfisher_bin-1.104.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b838313411fa2166a318a45aec2cfcc238e2f30f5292e309ca1129a73180c851", size = 12468386, upload-time = "2026-06-22T03:03:03.951Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a0/cc7ef0ac28f147cdfc9d80e4239fff11c1329831c6f57510c929e848753c/kingfisher_bin-1.104.0-py3-none-manylinux_2_17_aarch64.musllinux_1_2_aarch64.whl", hash = "sha256:0a94abbf2154ef8a3b4845cc0240e2321cdc19e0f5c7f585ea5252e76b242f68", size = 13943188, upload-time = "2026-06-22T03:03:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/17/79/827cfd7787885798a00b5ab905bdc866ef6f8deeff0f708679b06bc9baaa/kingfisher_bin-1.104.0-py3-none-manylinux_2_17_x86_64.musllinux_1_2_x86_64.whl", hash = "sha256:f381274b946f7f68ed72911770fff72024f2192c6e2e2158f2a7fbfda8c482fb", size = 14757594, upload-time = "2026-06-22T03:03:08.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/93/b0061fc69cd10382f647f9266823f213fd0b3f168f8b5bd9151a2370abb1/kingfisher_bin-1.104.0-py3-none-win_amd64.whl", hash = "sha256:f228d0dd61a738673b1c536e965a5661a83b1ee6ca64186a46ba6ea81ab4fd0b", size = 27697957, upload-time = "2026-06-22T03:03:11.268Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fb/f062665b4eb3f77e799cb6335e56bc2945aea83787888a6c1ab329858d0a/kingfisher_bin-1.104.0-py3-none-win_arm64.whl", hash = "sha256:a7774d9d11815ca946bd80b8c9df0f1d39c36cb5a21def3323b99d148dc63065", size = 26063704, upload-time = "2026-06-22T03:03:14.08Z" }, +] + [[package]] name = "kiwisolver" version = "1.4.9" @@ -3513,6 +3718,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/10/9f8af3e6f569685ce3af7faab51c8dd9d93b9c38eba339ca31c746119447/kubernetes-32.0.1-py2.py3-none-any.whl", hash = "sha256:35282ab8493b938b08ab5526c7ce66588232df00ef5e1dbe88a419107dc10998", size = 1988070, upload-time = "2025-02-18T21:06:31.391Z" }, ] +[[package]] +name = "linode-api4" +version = "5.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "polling" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/b5/fce03d9b81008dcc0fe4961ce10e140ac3ae5ab17f2cdd659763e4964c0d/linode_api4-5.45.0.tar.gz", hash = "sha256:af8a0a5638345ad467447112dcf5d58ec47e7dd192b89ce0c8537a1e5c435d04", size = 283375, upload-time = "2026-06-11T18:05:13.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/38/19e3c8f7b7a9dbeea2aa5af61f70162bff5131b3d39acbe73e8d0dd12972/linode_api4-5.45.0-py3-none-any.whl", hash = "sha256:3cc2650b13d8d3bc7735fa8e92a639669618f320471dc8e519db778c6020eacd", size = 158336, upload-time = "2026-06-11T18:05:11.799Z" }, +] + [[package]] name = "lxml" version = "6.1.0" @@ -4332,6 +4551,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/f5/65b66420c275e9b26513fdd6d84687403d11ac8be4650b67d1e5572b8f48/policyuniverse-1.5.1.20231109-py2.py3-none-any.whl", hash = "sha256:0b0ece0ee8285af31fc39ce09c82a551ca62e62bc2842e23952503bccb973321", size = 484251, upload-time = "2023-11-30T19:12:43.463Z" }, ] +[[package]] +name = "polling" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/c5/4249317962180d97ec7a60fe38aa91f86216533bd478a427a5468945c5c9/polling-0.3.2.tar.gz", hash = "sha256:3afd62320c99b725c70f379964bf548b302fc7f04d4604e6c315d9012309cc9a", size = 5189, upload-time = "2021-05-22T19:48:41.466Z" } + [[package]] name = "portalocker" version = "2.10.1" @@ -4448,8 +4673,8 @@ wheels = [ [[package]] name = "prowler" -version = "5.31.0" -source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b5bb85c9564f6ca6a7f66c851bb56bde719205ee" } +version = "5.32.0" +source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#5dac8a0a53272e4db68c476fb969dc03e88beb68" } dependencies = [ { name = "alibabacloud-actiontrail20200706" }, { name = "alibabacloud-credentials" }, @@ -4500,13 +4725,14 @@ dependencies = [ { name = "dash" }, { name = "dash-bootstrap-components" }, { name = "defusedxml" }, - { name = "detect-secrets" }, { name = "dulwich" }, { name = "google-api-python-client" }, { name = "google-auth-httplib2" }, { name = "h2" }, { name = "jsonschema" }, + { name = "kingfisher-bin" }, { name = "kubernetes" }, + { name = "linode-api4" }, { name = "markdown" }, { name = "microsoft-kiota-abstractions" }, { name = "msgraph-sdk" }, @@ -4536,7 +4762,7 @@ dependencies = [ [[package]] name = "prowler-api" -version = "1.33.0" +version = "1.34.0" source = { virtual = "." } dependencies = [ { name = "cartography" }, @@ -4606,7 +4832,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "cartography", specifier = "==0.135.0" }, + { name = "cartography", specifier = "==0.138.1" }, { name = "celery", specifier = "==5.6.2" }, { name = "defusedxml", specifier = "==0.7.1" }, { name = "dj-rest-auth", extras = ["with-social", "jwt"], specifier = "==7.0.1" }, @@ -5931,6 +6157,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, ] +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + [[package]] name = "werkzeug" version = "3.1.7" @@ -5945,16 +6203,16 @@ wheels = [ [[package]] name = "workos" -version = "6.0.4" +version = "6.0.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "httpx" }, { 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" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/0d/0a7f78912657f99412c788932ea1f3f4089916e77bdef7d2463842febe08/workos-6.0.8.tar.gz", hash = "sha256:43aa3f1992a0a4ca8933d9b6e5ada846dd3b1fe0ee10e64c876ee2000fc6090d", size = 178137, upload-time = "2026-04-24T18:48:03.203Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/f1/d2ab661e6dc2828a4c73e38f12630c3b109cfe2bc664ab70631c04f0db4b/workos-6.0.4-py3-none-any.whl", hash = "sha256:548668b3702673536f853ba72a7b5bbbc269e467aaf9ac4f477b6e0177df5e21", size = 511418, upload-time = "2026-04-16T03:09:27.098Z" }, + { url = "https://files.pythonhosted.org/packages/b2/3f/3d96da80d650b2f97d58af626053354584f619dbb769051e118bd9cd1ca5/workos-6.0.8-py3-none-any.whl", hash = "sha256:a00dd4930333aded2babbba824f8032eea05c5ca8c44d04a3fa068cf6be6e21a", size = 524505, upload-time = "2026-04-24T18:48:01.389Z" }, ] [[package]] diff --git a/contrib/aws/multi-account-securityhub/Dockerfile b/contrib/aws/multi-account-securityhub/Dockerfile index de5c57ba7b..7e5e7f5182 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@sha256:4b796c6df40a3350c7947747b59bdda230d0da6222287500e13b0a8e1574aad4 +ARG PROWLER_VERSION=latest@sha256:ebb4ab999f10cb7e7c256226c2873de9b3bf2f3d855f385e0164bcf34104bfba FROM toniblyx/prowler:${PROWLER_VERSION} diff --git a/contrib/reverse-proxy/docker-compose.reverse-proxy.yml b/contrib/reverse-proxy/docker-compose.reverse-proxy.yml index b8f8edec30..cb6bd9c3a2 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@sha256:8b1e78743a03dbb2c95171cc58639fef29abc8816598e27fb910ed2e621e589a + image: nginx:alpine@sha256:54f2a904c251d5a34adf545a72d32515a15e08418dae0266e23be2e18c66fefa container_name: prowler-nginx restart: unless-stopped ports: diff --git a/dashboard/compliance/cis_2_0_1_kubernetes.py b/dashboard/compliance/cis_2_0_1_kubernetes.py new file mode 100644 index 0000000000..94558f33ad --- /dev/null +++ b/dashboard/compliance/cis_2_0_1_kubernetes.py @@ -0,0 +1,24 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/compliance/cis_5_0_gcp.py b/dashboard/compliance/cis_5_0_gcp.py new file mode 100644 index 0000000000..94558f33ad --- /dev/null +++ b/dashboard/compliance/cis_5_0_gcp.py @@ -0,0 +1,24 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/compliance/cis_6_0_azure.py b/dashboard/compliance/cis_6_0_azure.py new file mode 100644 index 0000000000..9d33cc67a8 --- /dev/null +++ b/dashboard/compliance/cis_6_0_azure.py @@ -0,0 +1,25 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/compliance/cis_7_0_aws.py b/dashboard/compliance/cis_7_0_aws.py new file mode 100644 index 0000000000..94558f33ad --- /dev/null +++ b/dashboard/compliance/cis_7_0_aws.py @@ -0,0 +1,24 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/compliance/cis_7_0_m365.py b/dashboard/compliance/cis_7_0_m365.py new file mode 100644 index 0000000000..94558f33ad --- /dev/null +++ b/dashboard/compliance/cis_7_0_m365.py @@ -0,0 +1,24 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/docs/developer-guide/checks.mdx b/docs/developer-guide/checks.mdx index 2f0f45cd96..5334799d36 100644 --- a/docs/developer-guide/checks.mdx +++ b/docs/developer-guide/checks.mdx @@ -445,3 +445,5 @@ The metadata structure is enforced in code using a Pydantic model. For reference ## Specific Check Patterns Details for specific providers can be found in documentation pages named using the pattern `-details`. + +Checks that scan resources for plaintext secrets follow a dedicated batched structure. Refer to [Secret-Scanning Checks](/developer-guide/secret-scanning-checks) before creating or updating one. diff --git a/docs/developer-guide/configurable-checks.mdx b/docs/developer-guide/configurable-checks.mdx index 760dc80f58..f550ff4f52 100644 --- a/docs/developer-guide/configurable-checks.mdx +++ b/docs/developer-guide/configurable-checks.mdx @@ -42,10 +42,14 @@ When adding a new configurable check to Prowler, update the following files: ``` - **Provider Schema:** Add the typed field to the provider's Pydantic schema in `prowler/config/schema/.py`. This is required: the loader validates user configs against these schemas and the shipped `config.yaml` must round-trip with zero warnings. See [Adding a Parameter to the Provider Schema](#adding-a-parameter-to-the-provider-schema) below. - **Test Fixtures:** If tests depend on this configuration, add the variable to `tests/config/fixtures/config.yaml`. -- **Documentation:** Document the new variable in the list of configurable checks in `docs/tutorials/configuration_file.md`. +- **Documentation:** Document the new variable in the list of configurable checks in [Configuration File](/user-guide/cli/tutorials/configuration_file) (`docs/user-guide/cli/tutorials/configuration_file.mdx`). For a complete list of checks that already support configuration, see the [Configuration File Tutorial](/user-guide/cli/tutorials/configuration_file). + +Because a configurable check's verdict depends on the `audit_config` value it reads, a compliance requirement can lose meaning if the scan ran with a looser threshold than the control demands. Compliance frameworks can guard against this with **configuration guardrails**: a requirement declares the strictest configuration it tolerates and is forced to FAIL when the scan's config falls short. See [Configuration Guardrails for Requirements](/developer-guide/security-compliance-framework#configuration-guardrails-for-requirements). + + ## Adding a Parameter to the Provider Schema Most providers have a typed Pydantic schema in `prowler/config/schema/`, registered in `prowler/config/schema/registry.py`. When a config is loaded and the provider has a registered schema, `validate_provider_config` checks each user-supplied key against it, logs a warning, and drops any field that fails validation. The consumer's `.get(key, default)` then falls back to the built-in default. Providers without a registered schema are passed through unchanged. @@ -149,7 +153,6 @@ Only fields with a numeric range, a fixed value set, or a length cap are listed. | `max_days_secret_unused` | `7..365` days | | | `max_days_secret_unrotated` | `1..180` days | NIST IA-5: rotate quarterly; CIS ≤90 | | `min_kinesis_stream_retention_hours` | `24..8760` h | 1 day .. 1 year | -| `detect_secrets_plugins[].limit` | `0.0..10.0` | Shannon entropy threshold | | `shodan_api_key` | ≤512 chars | | ### Azure diff --git a/docs/developer-guide/secret-scanning-checks.mdx b/docs/developer-guide/secret-scanning-checks.mdx new file mode 100644 index 0000000000..5044366b7e --- /dev/null +++ b/docs/developer-guide/secret-scanning-checks.mdx @@ -0,0 +1,119 @@ +--- +title: 'Secret-Scanning Checks' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Prowler scans audited resources for plaintext secrets using [Kingfisher](https://github.com/mongodb/kingfisher), an open-source secret-scanning engine that Prowler invokes as a subprocess. This guide explains the structure every secret-scanning check must follow to keep scanning correct and efficient on large accounts. + + +Since Prowler 5.32.0 the secret-scanning checks scan with Kingfisher. Earlier versions used the `detect-secrets` library. + + +## Overview + +Secret detection runs through a single helper in `prowler/lib/utils/utils.py`: + +- **`detect_secrets_scan_batch(payloads, excluded_secrets=..., validate=...)`** scans many payloads in chunked subprocess invocations and returns a `{key: [findings]}` dictionary. To scan a single payload, pass a one-entry mapping (for example, `{0: data}`). + +Every Kingfisher invocation carries a fixed process-startup cost (around 100 ms). Scanning once per resource would spawn thousands of subprocesses on large accounts (for example, thousands of CloudWatch log groups). `detect_secrets_scan_batch` amortizes that cost: it writes each payload to a temporary file as it consumes them, runs one subprocess per chunk (500 payloads by default), and maps the findings back to each payload by key. + +## The Batched Structure + +Every secret-scanning check follows three phases. + +### Phase 1: Collect + +Define a generator that yields `(key, payload)` for each scannable unit. The generator builds payload strings only — it does not call Kingfisher. Lazy yielding keeps memory and temporary-disk usage bounded to a single chunk, which matters when an account holds thousands of resources. + +### Phase 2: Batch + +Call `detect_secrets_scan_batch` once with the generator. The helper consumes it in chunks, runs Kingfisher per chunk, and returns the keys that produced findings mapped to their finding lists. + +### Phase 3: Report + +Iterate the resources, look up the findings by key, and build one report per resource. Emit a finding for **every** iterated resource — never drop one silently. When a resource's payload cannot be prepared for scanning (for example, user data that fails to base64-decode or decompress), report it as `MANUAL` with a status explaining the scan could not inspect it, rather than omitting it or claiming `PASS`. + +```python +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.utils.utils import ( + annotate_verified_secrets, + detect_secrets_scan_batch, +) +from prowler.providers.aws.services.example.example_client import example_client + + +class example_resource_no_secrets(Check): + def execute(self): + findings = [] + excluded = example_client.audit_config.get("secrets_ignore_patterns", []) + validate = example_client.audit_config.get("secrets_validate", False) + resources = list(example_client.resources) + + # Phase 1: collect — builds strings only, no scan. + def payloads(): + for index, resource in enumerate(resources): + if resource.scannable_data: + yield index, serialize(resource) + + # Phase 2: batch — one call, chunked subprocesses. + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=excluded, validate=validate + ) + + # Phase 3: report — look up findings by key. + for index, resource in enumerate(resources): + report = Check_Report_AWS(metadata=self.metadata(), resource=resource) + report.status = "PASS" + report.status_extended = f"No secrets found in {resource.name}." + detect_secrets_output = batch_results.get(index) + if detect_secrets_output: + report.status = "FAIL" + report.status_extended = ( + f"Potential secret found in {resource.name} -> ..." + ) + annotate_verified_secrets(report, detect_secrets_output) + findings.append(report) + + return findings +``` + +## Choosing the Key + +The key maps each finding back to its source. Two shapes cover every check: + +- **One payload per resource:** use the resource index. This fits checks that serialize a single payload per resource, such as launch configurations, CloudFormation outputs, SSM documents, Step Functions definitions, and OpenStack metadata. +- **Several payloads per resource:** use a `(resource_index, fragment)` tuple, where the fragment identifies the variable, log stream, container, file, or version. Phase 3 groups the per-fragment findings to build the resource report. This fits CloudWatch log streams, ECS containers, CodeBuild variables, Glue arguments, and Lambda code files. + +Derive the indices from the same `list(...)` of resources in both Phase 1 and Phase 3 so the order stays stable and the keys align. + +## Preserving Per-Payload Results + +`detect_secrets_scan_batch` runs Kingfisher with `--no-dedup`, so a secret that appears in more than one payload is reported for each one. This reproduces the result of scanning each payload individually. Build payload strings exactly as a single scan would: serialize the same data and keep line ordering, because messages often map a finding's `line_number` back to a variable name or metadata key. + +## Validation and Severity + +`detect_secrets_scan_batch` accepts `validate`, read from `secrets_validate` in the provider configuration or the `--scan-secrets-validate` flag. When enabled, Kingfisher confirms whether each secret is live, and confirmed secrets carry `is_verified: True`. + +After marking a report as `FAIL`, pass the findings to `annotate_verified_secrets(report, findings)`. When any secret is verified, the helper escalates the finding to critical severity and appends a note that the secret was confirmed live. Validation stays off by default because it sends the discovered secret to the provider API. + +## Excluded Secrets + +`detect_secrets_scan_batch` applies `secrets_ignore_patterns` — regular expressions from the provider configuration — against each finding's source line and drops the matches, mirroring single-scan behavior. + +## Testing + +To assert on the verified-secret path, mock `detect_secrets_scan_batch` in the check module and return the keyed dictionary. For a single resource scanned at index `0`: + +```python +mock.patch( + "prowler.providers.aws.services.example.example_resource_no_secrets.example_resource_no_secrets.detect_secrets_scan_batch", + return_value={ + 0: [{"type": "...", "line_number": 1, "is_verified": True}] + }, +) +``` + +Most tests need no mock at all: they seed resources that contain example secrets and assert on the `FAIL` status and message, which exercises the real batched path. Refer to the [Testing](/developer-guide/unit-testing) documentation for the general structure. diff --git a/docs/developer-guide/security-compliance-framework.mdx b/docs/developer-guide/security-compliance-framework.mdx index cf756c4da0..75392ed3a7 100644 --- a/docs/developer-guide/security-compliance-framework.mdx +++ b/docs/developer-guide/security-compliance-framework.mdx @@ -2,6 +2,8 @@ title: 'Creating a New Security Compliance Framework in Prowler' --- +import { VersionBadge } from "/snippets/version-badge.mdx" + 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 @@ -23,7 +25,7 @@ Requirement coverage feeds the compliance percentage calculations and the metada | **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. +Auto-discovery happens in `get_bulk_compliance_frameworks_universal(provider)` (`prowler/lib/check/compliance_models.py`), 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. @@ -70,13 +72,13 @@ The file is auto-discovered — there is **no** need to register it in any `__in } ``` -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. +A `provider` field at the top level is **optional**. The framework's effective provider list is derived by `ComplianceFramework.get_providers()` (`compliance_models.py`) 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: +Declares the shape of the per-requirement `attributes` dict. When this field is present, the root validator `validate_attributes_against_metadata` (`compliance_models.py`) 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). @@ -192,6 +194,7 @@ Per requirement: - `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). +- `config_requirements`: optional list of configuration guardrails. Each entry asserts that a configurable check referenced by this requirement ran with a configuration strict enough to actually satisfy the requirement; otherwise the requirement is forced to FAIL. See [Configuration Guardrails for Requirements](#configuration-guardrails-for-requirements) for the full schema and semantics. In the universal schema the field name is lowercase (`config_requirements`); legacy files use `ConfigRequirements`. 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). @@ -258,7 +261,7 @@ prowler/lib/outputs/compliance// ### JSON schema reference -Every legacy compliance file is a JSON document with the following top-level keys. `Framework`, `Name` and `Provider` are validated non-empty by the root validator `framework_and_provider_must_not_be_empty` (`compliance_models.py:329`). +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`). | Field | Type | Required | Description | |---|---|---|---| @@ -280,10 +283,11 @@ Each entry in `Requirements` describes one control or requirement. | `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. | +| `ConfigRequirements` | array of objects | No | Optional [configuration guardrails](#configuration-guardrails-for-requirements). Each entry asserts that a configurable check ran with a configuration strict enough to satisfy the requirement; when it did not, the requirement is forced to FAIL. | #### Attribute Objects -`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. +`Attributes` is parsed against the union declared in `Compliance_Requirement.Attributes` (`compliance_models.py`). 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. 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. @@ -472,13 +476,188 @@ For NIST-style catalogs that use `Generic_Compliance_Requirement_Attribute`, no ### 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. +At load time, every legacy file is transparently adapted to a `ComplianceFramework` via `adapt_legacy_to_universal()` (`compliance_models.py`), which: (a) flattens the first element of `Attributes` into a flat `attributes` dict, (b) wraps `Checks` as `{provider_lower: [...]}`, (c) infers `attributes_metadata` from the matched Pydantic class via `_infer_attribute_metadata()`. The rest of Prowler (CSV/OCSF/PDF output, CLI table) then treats both formats identically. 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()` (legacy) is **fail-fast**: it calls `sys.exit(1)` on any `ValidationError` (`compliance_models.py`). - `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. +## Configuration Guardrails for Requirements + + + +Some requirements are only truly satisfied when the configurable checks behind them ran with a configuration strict enough to meet the control. A [configurable check](/developer-guide/configurable-checks) reads thresholds from the scan's `audit_config`, so loosening a value can make the check PASS while the requirement it backs is, in fact, not satisfied. + +A worked example: CIS AWS 6.0 requirement 2.11 ("credentials unused for 45 days or more are disabled") maps to `iam_user_accesskey_unused`, which is driven by the `max_unused_access_keys_days` config key. If a user raises that value to `120`, the check passes for a key unused for 90 days — yet the requirement explicitly demands a 45-day threshold, so the PASS is misleading. + +Configuration guardrails close that gap. A requirement declares the configuration it expects, and when the scan ran with a configuration too loose to honor it, the requirement is forced to **FAIL** in every compliance output, with the reason surfaced in the finding's extended status. + + +Guardrails are an **optional** safety net for configurable checks. A requirement that maps only to non-configurable checks does not need them. When the field is absent, behavior is unchanged. + + +### Where guardrails are declared + +The field is attached to each requirement and exists in both schemas: + +- **Legacy** (`prowler/compliance//...`): `ConfigRequirements`, a list of objects, validated against the `Compliance_Requirement_ConfigConstraint` Pydantic model (`prowler/lib/check/compliance_models.py`). +- **Universal** (`prowler/compliance/...`): `config_requirements`, the same list of objects as plain dicts on `UniversalComplianceRequirement`. + +When a legacy file is adapted to the universal model, `adapt_legacy_to_universal()` copies `ConfigRequirements` into `config_requirements` (`compliance_models.py`), so downstream code only ever reads one shape. + +### Constraint schema + +Each entry in the list is a single constraint with the following fields: + +| Field | Type | Required | Description | +|---|---|---|---| +| `Check` | string | Yes | The configurable check this constraint guards. Should be one of the requirement's `Checks`. Used only to build a human-readable reason. | +| `ConfigKey` | string | Yes | The `audit_config` key the check reads (for example `max_unused_access_keys_days`). | +| `Operator` | enum | Yes | How to compare the applied value against `Value`. One of `lte`, `gte`, `eq`, `in`, `subset`, `superset`. | +| `Value` | bool, int, float, string, or list | Yes | The strictest configuration the requirement tolerates. The accepted Python type depends on the operator (see below). | +| `Provider` | string | No | The provider this constraint applies to (e.g. `aws`). **Required for universal (multi-provider) frameworks**, where the same requirement maps checks across providers — the constraint is only evaluated when the scanned provider matches. Single-provider (legacy) frameworks omit it. | + +### Operators + +| Operator | Applied value satisfies the guardrail when… | Typical use | +|---|---|---| +| `lte` | `applied <= Value` | Maximum-age / maximum-count thresholds (e.g. `max_unused_access_keys_days <= 45`). | +| `gte` | `applied >= Value` | Minimum-retention / minimum-count thresholds. | +| `eq` | `applied == Value` | Boolean toggles or an exact required value (e.g. `mute_non_default_regions == false`). | +| `in` | `applied` is one of `Value` (a list) | The applied scalar must belong to an allowed set. | +| `subset` | `set(applied) <= set(Value)` | **Allowlist** configs — every applied value must already be permitted. Widening the allowlist with a weaker value (e.g. adding TLS `1.0` to `recommended_minimal_tls_versions`) breaks the guardrail. | +| `superset` | `set(applied) >= set(Value)` | **Denylist** configs — every forbidden value must remain forbidden. Removing an entry from a denylist (e.g. dropping a weak algorithm from `insecure_key_algorithms`) breaks the guardrail. | + + +`subset` / `superset` require both the applied value and `Value` to be lists; any other type is treated as not satisfied. For `eq` against a boolean, declare `Value` as a JSON boolean (`false`, not `0`) — the model keeps booleans distinct from integers. + + +### How guardrails are evaluated + +All evaluation lives in one shared module, `prowler/lib/check/compliance_config_eval.py`, consumed by every compliance output (CSV, OCSF, and the CLI tables) and reused by the Prowler App backend so the rule is defined exactly once. + +1. The applied configuration is the scan-global `audit_config` (the same mapping for every resource and region), resolved via `get_scan_audit_config()`. +2. For each requirement that declares constraints, `evaluate_config_constraints()` walks the list and returns `(is_compliant, reason)`. The requirement is compliant when **every** explicitly-set key satisfies its constraint. +3. A constraint tagged with a `Provider` that does **not** match the provider being scanned (resolved via `get_scan_provider_type()`) is **skipped**. This scopes a universal framework's constraints to the right provider, so a guardrail authored for an AWS check never affects a GCP or Azure scan of the same requirement. Untagged constraints (legacy single-provider frameworks) always apply. +4. A constraint whose `ConfigKey` is **not present** in `audit_config` is **skipped** — the check's built-in default is assumed to already match what the requirement expects. This is why nothing changes for the default configuration. +5. When a constraint is violated, the finding's status is overridden to `FAIL` and a plain-language explanation is prepended to `status_extended` (via `apply_config_status()`). The message opens with `Configuration not valid for this requirement.` and names the check, the value the scan applied, what the requirement needs and how to fix it. For the table generators, `get_effective_status()` applies the same FAIL roll-up so per-section counts stay consistent. + + +Guardrails only ever make a result **stricter** (they can turn PASS into FAIL); they never relax a real FAIL into PASS. A requirement with no constraints, or whose keys all use defaults, is reported exactly as before. + + +### Example: legacy framework + +From `prowler/compliance/aws/cis_6.0_aws.json`, requirement 2.11 declares two guardrails — one per configurable check it maps to: + +```json title="prowler/compliance/aws/cis_6.0_aws.json" +{ + "Id": "2.11", + "Description": "Ensure credentials unused for 45 days or more are disabled.", + "Checks": [ + "iam_user_accesskey_unused", + "iam_user_console_access_unused" + ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], + "Attributes": [ /* ... */ ] +} +``` + +A boolean guardrail from the same file: requirement 2.5 (IAM Access Analyzer) only holds when regions are not muted, so a scan with `mute_non_default_regions: true` cannot be trusted for it: + +```json +"ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } +] +``` + +### Example: universal framework + +The universal schema uses the lowercase `config_requirements` key with the identical object shape: + +```json +{ + "id": "MF-2.1", + "name": "Restrict TLS to modern versions", + "description": "Endpoints must negotiate only TLS 1.2 or higher.", + "checks": { + "aws": ["elbv2_listener_ssl_listeners"] + }, + "config_requirements": [ + { + "Check": "elbv2_listener_ssl_listeners", + "Provider": "aws", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": ["TLS 1.2", "TLS 1.3"] + } + ] +} +``` + +Each constraint declares the `Provider` it targets so the guardrail is only evaluated on scans of that provider — essential for universal frameworks like CSA CCM and DORA, where one requirement maps checks across `aws`, `azure`, `gcp` and more. Because the operator is `subset`, adding `"TLS 1.0"` to `recommended_minimal_tls_versions` widens the allowlist beyond `["TLS 1.2", "TLS 1.3"]` and the requirement is forced to FAIL. + +### What the user sees + +With a loosened config, the affected requirement's findings report: + +```text +Status: FAIL +StatusExtended: Configuration not valid for this requirement. The check + iam_user_accesskey_unused has max_unused_access_keys_days set + to 120, but the requirement needs a value of 45 or lower. + Update it to 45 or lower. +``` + +The same `Configuration not valid for this requirement.` message appears identically across the CSV, OCSF, and console-table outputs. + +### Authoring guidelines + +- Declare a guardrail only for keys whose value actually changes whether the requirement is met. Most configurable checks do not need one. +- Set `Value` to the **strictest** configuration the control tolerates — the same number the control text cites (CIS 45 days, NIST ≤90, and so on). +- Keep `ConfigKey` spelled exactly as the check reads it from `audit_config`; an unknown key is never present in the config and the constraint is silently skipped. +- In a **universal (multi-provider) framework**, always set `Provider` to the provider that owns `Check` — otherwise the guardrail would leak onto scans of the other providers the requirement maps. Legacy single-provider files omit it. +- Pick the operator from the value's role: a max threshold is `lte`, a min threshold is `gte`, a toggle is `eq`, an allowlist is `subset`, a denylist is `superset`. +- An unrecognized operator does **not** block the requirement — a malformed constraint is treated as satisfied rather than failing the whole framework. Validate your JSON with the tests below. + +### Testing guardrails + +The shared evaluator and the per-output integration are covered by: + +- `tests/lib/check/compliance_config_eval_test.py` — operator semantics, skipped-key behavior, and the FAIL override. +- `tests/lib/check/compliance_config_constraint_model_test.py` — model validation (types, operator enum, bool-vs-int). +- `tests/lib/check/compliance_config_requirements_data_test.py` — sanity-checks the guardrails shipped in the JSON catalog. +- Per-output tests under `tests/lib/outputs/compliance/` (CIS AWS/Azure, ENS AWS, OCSF, universal table) confirm the override reaches each format. + +Run them with: + +```bash +uv run pytest -n auto \ + tests/lib/check/compliance_config_eval_test.py \ + tests/lib/check/compliance_config_constraint_model_test.py \ + tests/lib/check/compliance_config_requirements_data_test.py \ + tests/lib/outputs/compliance/ +``` + ## 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`. @@ -609,7 +788,7 @@ The following issues are the most common when contributing a compliance framewor - **`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. +- **`attributes_metadata validation failed` (universal).** The root validator in `compliance_models.py` 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`. diff --git a/docs/docs.json b/docs/docs.json index 5b88b5d2da..0de5e1ff09 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -125,7 +125,10 @@ "user-guide/tutorials/prowler-app-multi-tenant", "user-guide/tutorials/prowler-app-api-keys", "user-guide/tutorials/prowler-import-findings", + "user-guide/tutorials/prowler-scan-scheduling", "user-guide/tutorials/prowler-alerts", + "user-guide/tutorials/prowler-app-scan-configuration", + "user-guide/tutorials/prowler-app-findings-triage", { "group": "Mutelist", "expanded": true, @@ -235,6 +238,7 @@ "user-guide/providers/azure/authentication", "user-guide/providers/azure/use-non-default-cloud", "user-guide/providers/azure/subscriptions", + "user-guide/providers/azure/resource-groups", "user-guide/providers/azure/create-prowler-service-principal" ] }, @@ -357,7 +361,8 @@ "group": "Okta", "pages": [ "user-guide/providers/okta/getting-started-okta", - "user-guide/providers/okta/authentication" + "user-guide/providers/okta/authentication", + "user-guide/providers/okta/retry-configuration" ] }, { @@ -396,6 +401,7 @@ "developer-guide/provider", "developer-guide/services", "developer-guide/checks", + "developer-guide/secret-scanning-checks", "developer-guide/outputs", "developer-guide/integrations", "developer-guide/security-compliance-framework", diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index 598b2ac44a..bb2a1fe633 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -128,8 +128,8 @@ To update the environment file: Edit the `.env` file and change version values: ```env -PROWLER_UI_VERSION="5.31.0" -PROWLER_API_VERSION="5.31.0" +PROWLER_UI_VERSION="5.32.0" +PROWLER_API_VERSION="5.32.0" ``` diff --git a/docs/images/prowler-app/findings-triage/findings-triage-note-modal.png b/docs/images/prowler-app/findings-triage/findings-triage-note-modal.png new file mode 100644 index 0000000000..dba20c300a Binary files /dev/null and b/docs/images/prowler-app/findings-triage/findings-triage-note-modal.png differ diff --git a/docs/images/prowler-app/findings-triage/findings-triage-status-dropdown.png b/docs/images/prowler-app/findings-triage/findings-triage-status-dropdown.png new file mode 100644 index 0000000000..8edf86b86b Binary files /dev/null and b/docs/images/prowler-app/findings-triage/findings-triage-status-dropdown.png differ diff --git a/docs/images/prowler-app/findings-triage/findings-triage-table.png b/docs/images/prowler-app/findings-triage/findings-triage-table.png new file mode 100644 index 0000000000..d3543ce131 Binary files /dev/null and b/docs/images/prowler-app/findings-triage/findings-triage-table.png differ diff --git a/docs/images/prowler-app/scan-scheduling/edit-scan-schedule.png b/docs/images/prowler-app/scan-scheduling/edit-scan-schedule.png new file mode 100644 index 0000000000..e76809b810 Binary files /dev/null and b/docs/images/prowler-app/scan-scheduling/edit-scan-schedule.png differ diff --git a/docs/images/prowler-app/scan-scheduling/launch-scan-schedule.png b/docs/images/prowler-app/scan-scheduling/launch-scan-schedule.png new file mode 100644 index 0000000000..980b16bf7f Binary files /dev/null and b/docs/images/prowler-app/scan-scheduling/launch-scan-schedule.png differ diff --git a/docs/images/prowler-app/scan-scheduling/providers-scan-schedule.png b/docs/images/prowler-app/scan-scheduling/providers-scan-schedule.png new file mode 100644 index 0000000000..a7dab3310c Binary files /dev/null and b/docs/images/prowler-app/scan-scheduling/providers-scan-schedule.png differ diff --git a/docs/images/prowler-app/scan-scheduling/scheduled-scans-tab.png b/docs/images/prowler-app/scan-scheduling/scheduled-scans-tab.png new file mode 100644 index 0000000000..26b4b160ec Binary files /dev/null and b/docs/images/prowler-app/scan-scheduling/scheduled-scans-tab.png differ diff --git a/docs/snippets/subscription-banner.mdx b/docs/snippets/subscription-banner.mdx new file mode 100644 index 0000000000..8313997c84 --- /dev/null +++ b/docs/snippets/subscription-banner.mdx @@ -0,0 +1,8 @@ +export const SubscriptionBanner = ({ children }) => { + return ( + + This feature is available exclusively in Prowler Cloud and Prowler Enterprise with a subscription. + {children} + + ); +}; diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx index 657549f8b0..07508ddc12 100644 --- a/docs/user-guide/cli/tutorials/configuration_file.mdx +++ b/docs/user-guide/cli/tutorials/configuration_file.mdx @@ -2,6 +2,8 @@ title: "Configuration File" --- +import { VersionBadge } from "/snippets/version-badge.mdx" + Several Prowler's checks have user configurable variables that can be modified in a common **configuration file**. This file can be found in the following [path](https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml): ``` @@ -24,6 +26,7 @@ The following list includes all the AWS checks with configurable variables that |---------------------------------------------------------------|--------------------------------------------------|-----------------| | `acm_certificates_expiration_check` | `days_to_expire_threshold` | Integer | | `acmpca_certificate_authority_pqc_key_algorithm` | `acmpca_pqc_key_algorithms` | List of Strings | +| `apigateway_restapi_no_secrets_in_stage_variables` | `secrets_ignore_patterns` | List of Strings | | `appstream_fleet_maximum_session_duration` | `max_session_duration_seconds` | Integer | | `appstream_fleet_session_disconnect_timeout` | `max_disconnect_timeout_in_seconds` | Integer | | `appstream_fleet_session_idle_disconnect_timeout` | `max_idle_disconnect_timeout_in_seconds` | Integer | @@ -86,6 +89,91 @@ The following list includes all the AWS checks with configurable variables that | `vpc_endpoint_services_allowed_principals_trust_boundaries` | `trusted_account_ids` | List of Strings | | `opensearch_service_domains_not_publicly_accessible` | `trusted_ips` | List of Strings | +### Resource Scan Limit + + + +Some AWS services accumulate large numbers of resources (EBS snapshots, backup recovery points, CloudWatch log groups, Lambda functions, ECS task definitions, and CodeArtifact packages). Scanning every resource increases scan time, cost, API throttling, and finding volume. By default, Prowler scans every resource. Configure a positive resource scan limit to cap how many resources Prowler analyzes for these high-volume AWS resource paths. + +The global default applies to the supported resources below and is overridable per resource. The default global value is `0`, which disables the limit and scans every resource. A global `null` value is also unlimited. For per-resource values, `null` means inherit the global default; set `0` or a negative value to disable that resource limit explicitly. Positive values enable limits. + + +When positive resource scan limits are configured, compliance results are based only on the selected resources, not on the full set of matching resources in the account. Treat compliance summaries and percentages as partial evidence, because unselected resources are not analyzed and can change the real compliance posture. + + +#### Global Behavior + +Resource scan limits select resources for analysis. They do not cap, prioritize, or reorder findings. + +* **`0`, negative, or global `null` values:** Disable the limit and keep the legacy behavior for that resource path. Prowler analyzes every discovered matching resource. +* **Positive values:** Select at most that many resources for the affected resource path. A selected resource can produce zero, one, or many findings. +* **No PASS/FAIL prioritization:** Prowler does not inspect the compliance result before selecting resources. Limits do not prefer failed resources, passed resources, or resources with more findings. +* **Latest-first where possible:** When AWS exposes timestamps or useful ordering, Prowler selects the newest resources first. When AWS only exposes API order, Prowler preserves that API order and documents the behavior as best effort. +* **Findings are downstream:** Checks only evaluate the resources exposed by the service client after selection. Findings from unselected resources are not produced because those resources are not analyzed. + +Exact list API call reduction depends on each AWS API's ordering and pagination capabilities. When Prowler must enumerate candidates locally to select the latest resources, list calls may still read candidates, but expensive per-resource enrichment calls are bounded to the selected resources for the supported paths below. + +#### Full Collections Versus Limited Analysis Sets + +Some checks need lightweight evidence from a complete resource collection to avoid incorrect cross-service conclusions, while other checks perform primary analysis on a limited resource set. + +Prowler keeps full lightweight collections where they are needed for cross-service evidence. For example: + +* **Lambda security groups and regions:** Prowler records security groups used by all discovered Lambda functions and the regions where functions exist before it limits Lambda functions for primary Lambda checks. This helps Amazon EC2 and Amazon Inspector checks avoid false positives such as treating Lambda security groups as unused or assuming a region has no Lambda functions. +* **CloudWatch `all_log_groups`:** Prowler records all discovered CloudWatch log groups in `all_log_groups` before limiting the primary `log_groups` analysis set. Other services can still resolve log group evidence, while CloudWatch log group checks only analyze the selected log groups. + +This split is intentional. It reduces expensive per-resource analysis calls without discarding lightweight context that other services need for accurate results. + +#### Supported AWS Resource Limits + +| Value | Scope | Type | +|-------|-------|------| +| `max_scanned_resources_per_service` | Global default for all supported high-volume AWS resources (default `0`, disabled/unlimited) | Integer | +| `max_ebs_snapshots` | EBS snapshots (`ec2_ebs_*` checks) | Integer | +| `max_backup_recovery_points` | Backup recovery points (`backup_recovery_point_*`) | Integer | +| `max_cloudwatch_log_groups` | CloudWatch log groups (`cloudwatch_log_group_*`) | Integer | +| `max_lambda_functions` | Lambda functions (`awslambda_function_*`) | Integer | +| `max_ecs_task_definitions` | ECS task definitions (`ecs_task_definitions_*`) | Integer | +| `max_codeartifact_packages` | CodeArtifact packages (`codeartifact_packages_*`) | Integer | + +#### Resource Limit Behavior By Resource Path + +| Resource Path | What Prowler Discovers | What A Positive Limit Selects For Analysis | Ordering And Latest Behavior | AWS Calls Reduced | Drawbacks And Consequences | +|---------------|------------------------|--------------------------------------------|------------------------------|-------------------|----------------------------| +| EBS snapshots (`max_ebs_snapshots`) | Prowler lists self-owned snapshots and keeps lightweight evidence that volumes and regions have snapshots. | The selected EBS snapshots exposed to `ec2_ebs_*` checks. | Prowler sorts discovered snapshots by `StartTime` newest first, then applies the limit. Snapshots without a timestamp sort last. | Bounds expensive per-snapshot public attribute checks to selected snapshots. Snapshot listing still runs so Prowler can choose the newest snapshots and keep volume/region evidence. | Older unselected snapshots are not analyzed by snapshot checks. A public, unencrypted, or otherwise noncompliant older snapshot can be missed when the limit is lower than the number of snapshots. | +| Backup recovery points (`max_backup_recovery_points`) | Prowler lists backup vaults, plans, selections, and recovery point candidates in discovered vaults. | The selected recovery points exposed to `backup_recovery_point_*` checks and tag hydration. | Prowler sorts discovered recovery points by `CreationDate` newest first across vaults, then applies the limit. Recovery points without a timestamp sort last. | Bounds recovery point tag calls to selected recovery points. Vault and recovery point list calls still run so Prowler can choose the newest points. | Older unselected recovery points are not analyzed. A nonencrypted or otherwise noncompliant older recovery point can be missed. | +| CloudWatch log groups (`max_cloudwatch_log_groups`) | Prowler lists log groups into both `all_log_groups` and the primary `log_groups` collection. `all_log_groups` remains available as lightweight cross-service evidence. | The selected log groups exposed to `cloudwatch_log_group_*` checks, tag hydration, and log event retrieval for checks that need log contents. | Prowler sorts discovered log groups by `creationTime` newest first, then applies the limit. Log groups without a creation time sort last. | Bounds tag calls and log event retrieval to selected log groups. Log group listing still runs to build `all_log_groups` and choose newest log groups. | Older unselected log groups are not analyzed by CloudWatch log group checks. Retention, encryption, or secrets-in-logs issues in older log groups can be missed, although cross-service evidence can still use `all_log_groups`. | +| Lambda functions (`max_lambda_functions`) | Prowler lists Lambda functions and records lightweight security group and region evidence for all discovered functions. | The selected Lambda functions exposed to `awslambda_function_*` checks and per-function enrichment such as tags, policies, function URLs, and event source mappings. | Prowler sorts discovered functions by `LastModified` newest first, then applies the limit. Functions without `LastModified` sort last. | Bounds per-function enrichment calls to selected functions. Function listing still runs to choose newest functions and keep security group/region evidence. | Older unselected functions are not analyzed by Lambda checks. Runtime, policy, URL, environment secret, or dead-letter queue issues in older functions can be missed. Cross-service checks can still use full Lambda security group and region evidence to avoid false positives. | +| ECS task definitions (`max_ecs_task_definitions`) | Prowler lists ECS task definition ARN candidates in each region. Candidate ARNs can remain visible and discoverable through AWS list operations, even when not all are described. | The selected task definitions that Prowler describes and exposes to `ecs_task_definitions_*` checks. | Selection is not random. Prowler calls `ListTaskDefinitions` with `sort=DESC`, which asks AWS to return task definition ARNs in descending family and revision order. Prowler then interleaves regional candidate lists to avoid starving later regions before applying the limit. This selects the latest task definition revisions according to the ARN order AWS provides, while preserving regional fairness. | Bounds `DescribeTaskDefinition` calls to selected task definitions. Prowler may still list candidates so it can select the bounded set and keep discovery deterministic. | Unselected task definitions are not described or analyzed. Issues in older task definition revisions, or in lower-priority families outside the selected AWS `sort=DESC` order, can be missed. Because ECS ordering is family/revision based rather than a registration timestamp sort across every family, this is latest-first according to AWS task definition ARN ordering, not a global newest-by-time guarantee. | +| CodeArtifact packages (`max_codeartifact_packages`) | Prowler lists CodeArtifact repositories and lazily lists packages inside them. | The selected packages exposed to `codeartifact_packages_*` checks, including latest-version metadata for those packages. | AWS `ListPackages` does not provide a newest-package timestamp ordering in this path. Prowler preserves repository order and package API order, then applies the limit. Latest package version metadata is retrieved for selected packages with `sortBy=PUBLISHED_TIME` and `maxResults=1`. | Bounds `ListPackageVersions` calls to selected packages and can stop package listing once the limit is reached. Repository listing still runs. | Package selection is best effort by API order, not newest package order. Packages outside the selected repository/API order are not analyzed, so origin restriction or latest-version issues can be missed. | + +Use limits when scan duration, API throttling, or cost are more important than exhaustive coverage for these high-volume resources. Keep limits disabled when you need complete evidence for every resource in the affected checks. + +### Validating Discovered Secrets + + + +By default, the secret-scanning checks run fully offline: secrets are detected but never sent anywhere. Setting `secrets_validate` to `True` additionally confirms whether each discovered secret is live by authenticating with it against the corresponding provider API. The discovered secret itself serves as the credential, so Prowler requires no additional permissions to validate it. + +`secrets_validate` applies to every AWS secret-scanning check listed above (those that accept `secrets_ignore_patterns`). The `--scan-secrets-validate` CLI flag is provider-wide: it also enables validation for the secret-scanning checks of other providers, such as the OpenStack metadata checks. + +To enable validation through the configuration file, set the value under the `aws` section: + +```yaml +aws: + secrets_validate: True +``` + +To enable validation for a single scan (any provider), use Prowler CLI: + +``` +prowler aws --scan-secrets-validate +``` + + +Secret validation makes outbound network calls that authenticate with each discovered secret. The credential is exercised against the provider, so the call appears in the audited account's logs and can trigger its monitoring (for example, AWS CloudTrail records the validation request). Validation stays disabled by default so that scans remain fully offline. + + ## Azure @@ -191,6 +279,19 @@ aws: # AWS Global Configuration # aws.mute_non_default_regions --> Set to True to muted failed findings in non-default regions for AccessAnalyzer, GuardDuty, SecurityHub, DRS and Config mute_non_default_regions: False + + # AWS Resource Scan Limit Configuration + # Disabled by default: scan every resource unless a positive limit is configured. + # Findings are not capped. Set to 0 (or a negative value) to disable the limit. + # aws.max_scanned_resources_per_service --> global default for all services below + max_scanned_resources_per_service: 0 + # Per-service overrides. Leave as null to fall back to the global default. + max_ebs_snapshots: null + max_backup_recovery_points: null + max_cloudwatch_log_groups: null + max_lambda_functions: null + max_ecs_task_definitions: null + max_codeartifact_packages: null # If you want to mute failed findings only in specific regions, create a file with the following syntax and run it with `prowler aws -w mutelist.yaml`: # Mutelist: # Accounts: diff --git a/docs/user-guide/cli/tutorials/pentesting.mdx b/docs/user-guide/cli/tutorials/pentesting.mdx index 0ad5313611..a9a8c728df 100644 --- a/docs/user-guide/cli/tutorials/pentesting.mdx +++ b/docs/user-guide/cli/tutorials/pentesting.mdx @@ -6,20 +6,34 @@ Prowler has some checks that analyse pentesting risks (Secrets, Internet Exposed ## Detect Secrets -Prowler uses `detect-secrets` library to search for any secrets that are stores in plaintext within your environment. +Prowler scans for secrets stored in plaintext within the audited environment using [Kingfisher](https://github.com/mongodb/kingfisher), an open-source secret-scanning engine. By default these scans run fully offline, so no data leaves the audited environment. Discovered secrets can optionally be validated against the provider APIs to confirm whether they are live — see [Validating Discovered Secrets](/user-guide/cli/tutorials/configuration_file#validating-discovered-secrets). -The actual checks that have this functionality are the following: +The checks with this functionality are the following. +AWS: + +- apigateway\_restapi\_no\_secrets\_in\_stage\_variables - autoscaling\_find\_secrets\_ec2\_launch\_configuration - awslambda\_function\_no\_secrets\_in\_code - awslambda\_function\_no\_secrets\_in\_variables - cloudformation\_stack\_outputs\_find\_secrets +- cloudwatch\_log\_group\_no\_secrets\_in\_logs +- codebuild\_project\_no\_secrets\_in\_variables - ec2\_instance\_secrets\_user\_data - ec2\_launch\_template\_no\_secrets - ecs\_task\_definitions\_no\_environment\_secrets +- glue\_etl\_jobs\_no\_secrets\_in\_arguments - ssm\_document\_secrets +- stepfunctions\_statemachine\_no\_secrets\_in\_definition -To execute detect-secrets related checks, you can run the following command: +OpenStack: + +- compute\_instance\_metadata\_sensitive\_data +- blockstorage\_volume\_metadata\_sensitive\_data +- blockstorage\_snapshot\_metadata\_sensitive\_data +- objectstorage\_container\_metadata\_sensitive\_data + +To execute the secret-scanning checks, run the following command: ```console prowler --categories secrets diff --git a/docs/user-guide/providers/azure/resource-groups.mdx b/docs/user-guide/providers/azure/resource-groups.mdx new file mode 100644 index 0000000000..323193ea49 --- /dev/null +++ b/docs/user-guide/providers/azure/resource-groups.mdx @@ -0,0 +1,47 @@ +--- +title: 'Azure Resource Group Scope' +--- + +Prowler supports narrowing security scans to specific resource groups within Azure subscriptions. This is useful when you want to audit only a subset of resources rather than scanning an entire subscription. + +By default, Prowler scans all resource groups it has permission to access. Passing `--azure-resource-group` limits the scan to only the specified resource groups across all accessible subscriptions. + +## Configuring Resource Group Scoped Scans + +To restrict a scan to one or more resource groups, pass them as arguments using the `--azure-resource-group` flag: + +```console +prowler azure --az-cli-auth --azure-resource-group ... +``` + +For example, to scan only `rg-production` and `rg-staging`: + +```console +prowler azure --az-cli-auth --azure-resource-group rg-prod1 rg-prod2 +``` + +This works with all supported authentication methods: + +```console +# Service Principal +prowler azure --sp-env-auth --azure-resource-group rg-production + +# Browser +prowler azure --browser-auth --tenant-id --azure-resource-group rg-production + +# Managed Identity +prowler azure --managed-identity-auth --azure-resource-group rg-production +``` + +## How It Works + +When `--azure-resource-group` is provided, Prowler validates each specified resource group against all accessible subscriptions. A resource group is included in the scan if it exists in **at least one** subscription. + +- If a resource group is found in one or more subscriptions, it will be scanned in those subscriptions only. +- If a resource group is **not found in any** subscription, Prowler logs a warning and skips it. +- If **none** of the provided resource groups are found across any subscription, Prowler logs a warning and no resource group scoped checks will run. +- Resource group names are matched case-insensitively, so `MyGroup` and `mygroup` are treated as the same group, mirroring Azure's own behavior. + + +If `--azure-resource-group` is used, checks that apply to specific resources are limited to the relevant resource groups. But if checks that apply to tenant or subscription scope (identity, policy, or subscription-level configuration checks) are involved, then these checks will run in their natural scope. + diff --git a/docs/user-guide/providers/okta/retry-configuration.mdx b/docs/user-guide/providers/okta/retry-configuration.mdx new file mode 100644 index 0000000000..1575cf6329 --- /dev/null +++ b/docs/user-guide/providers/okta/retry-configuration.mdx @@ -0,0 +1,123 @@ +--- +title: "Okta Rate Limit Configuration in Prowler" +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Prowler's Okta Provider manages API rate limits with two complementary controls: + +- **Request throttling (proactive):** Prowler paces outbound requests through a shared limiter so scans stay under Okta's rate limits and rarely trigger a rate-limit response in the first place. +- **Retries (reactive):** When Okta still returns a rate-limit response (HTTP 429), the official Okta Python SDK reads the `X-Rate-Limit-Reset` header and waits until the window resets before retrying. This acts as a safety net for occasional bursts. + +Both controls are configurable through the configuration file or command line flags. + +## Request Throttling (Requests per Second) + +Throttling is the primary control for avoiding rate limits. Prowler limits the aggregate number of Okta API requests per second across every service in a scan. + +### Using the Command Line Flag + +```bash +prowler okta --okta-requests-per-second 4 +``` + +Set the value to `0` to disable throttling. + +### Using the Configuration File + +```yaml +okta: + # Maximum aggregate Okta API requests per second. Default: 4. Set to 0 to disable. + okta_requests_per_second: 4 +``` + +Okta enforces rate limits per endpoint, so this single global cap is a deliberately simple control. Lower the value if scans still hit limits on large organizations; raise it to scan faster when the organization has generous limits. + +## Retries + +Retries cover the cases throttling does not prevent, such as short bursts or per-endpoint limits lower than the global cap. + +### Using the Command Line Flag + +```bash +prowler okta --okta-retries-max-attempts 8 +``` + +### Using the Configuration File + +```yaml +okta: + # Maximum retries on HTTP 429. Default: 5. + okta_max_retries: 8 + # Per-request timeout in seconds. Default: 300. + okta_request_timeout: 300 +``` + +The command line flags override the configuration file values. + +## How It Works + +- **Automatic detection:** The Okta SDK retries the retryable statuses 429, 503, and 504. +- **Reset-aware backoff:** On a 429 response the SDK sleeps until the `X-Rate-Limit-Reset` window before each retry, rather than using a fixed delay. +- **Bounded attempts:** `okta_max_retries` caps how many times a single request is retried. The Okta SDK default is 2, which is often too low for large organizations, so Prowler defaults to 5. + +## Request Timeout + +The `okta_request_timeout` setting plays a dual role in the Okta SDK: + +- It is the per-request socket timeout, bounding how long a single HTTP call can hang. +- It is also the total wall-clock budget for the whole retry-and-backoff loop of one request. + +For this reason, the value defaults to 300 seconds rather than 0 (no timeout). A value of 0 leaves hung connections unbounded, while a value that is too low cuts the rate-limit waits short and reintroduces the errors. As a guideline, keep `okta_request_timeout` greater than or equal to `okta_max_retries` multiplied by 60 when raising the retry count, because Okta reset windows are typically up to one minute. + +## Error Example Handled + +``` +Okta HTTP 429: Too Many Requests. Hit rate limit. Retry request in 42 seconds. +``` + +## Validation + +### Debug Logging + +To confirm that throttling and retries are active, run a scan with debug logging: + +```bash +prowler okta --okta-requests-per-second 4 --log-level DEBUG --log-file debuglogs.txt +``` + +### Check the Messages + +```bash +grep -i "throttling\|rate limit\|retry" debuglogs.txt +``` + +### Expected Output + +When throttling is enabled, Prowler logs the configured rate at startup: + +``` +Okta request throttling enabled at 4 req/s +``` + +If a rate limit is still hit, the SDK logs the backoff: + +``` +Hit rate limit. Retry request in 42 seconds. +``` + +## Troubleshooting + +If scans continue to hit rate limits: + +1. Lower `--okta-requests-per-second` so requests are paced more conservatively. +2. Raise `--okta-retries-max-attempts` (and keep `okta_request_timeout` proportionally large) so the safety net absorbs more bursts. +3. Review the rate-limit allocation for the Okta organization and request an increase if needed. +4. Verify throttling and retry behavior with debug logging. + +## Official References + +- [Okta Rate Limits](https://developer.okta.com/docs/reference/rate-limits/) +- [Okta SDK for Python](https://github.com/okta/okta-sdk-python) diff --git a/docs/user-guide/tutorials/prowler-alerts.mdx b/docs/user-guide/tutorials/prowler-alerts.mdx index 3fa49f84ff..9f01a77910 100644 --- a/docs/user-guide/tutorials/prowler-alerts.mdx +++ b/docs/user-guide/tutorials/prowler-alerts.mdx @@ -4,14 +4,13 @@ description: 'Create email alerts from Prowler Cloud findings to monitor relevan --- import { VersionBadge } from "/snippets/version-badge.mdx" +import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" Alerts notify recipients by email when security findings match saved filter conditions. Use Alerts to track high-priority findings, monitor specific providers or services, and keep teams informed about scan results that match defined criteria. - -This feature is available exclusively in **Prowler Cloud** and **Prowler Enterprise** with a [paid subscription](https://prowler.com/pricing). - + ## Prerequisites diff --git a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx index 23d8b8d5a2..c463e3144b 100644 --- a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx +++ b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx @@ -3,13 +3,13 @@ title: "Attack Paths" description: "Identify privilege escalation chains and security misconfigurations across cloud environments using graph-based analysis." --- -import { VersionBadge } from "/snippets/version-badge.mdx" +import { VersionBadge } from "/snippets/version-badge.mdx"; Attack Paths analyzes relationships between cloud resources, permissions, and security findings to detect how privileges can be escalated and how misconfigurations can be exploited by threat actors. -By mapping these relationships as a graph, Attack Paths reveals risks that individual security checks cannot detect on their own — such as an IAM role that can escalate its own permissions, or a chain of policies that grants unintended access to sensitive resources. +By mapping these relationships as a graph, Attack Paths reveals risks that individual security checks cannot detect on their own, such as an IAM role that can escalate its own permissions, or a chain of policies that grants unintended access to sensitive resources. Attack Paths is currently available for **AWS** providers. Support for @@ -21,7 +21,7 @@ By mapping these relationships as a graph, Attack Paths reveals risks that indiv The following prerequisites are required for Attack Paths: - **An AWS provider is configured** with valid credentials in Prowler App. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws). -- **At least one scan has completed** on the configured AWS provider. Attack Paths scans run automatically alongside regular security scans — no separate configuration is required. +- **At least one scan has completed** on the configured AWS provider. Attack Paths scans run automatically alongside regular security scans, no separate configuration is required. ## How Attack Paths Scans Work @@ -145,11 +145,10 @@ LIMIT 25 **IAM principals with wildcard Allow statements:** ```cypher -MATCH (principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) -WHERE stmt.effect = 'Allow' - AND ANY(action IN stmt.action WHERE action = '*') -RETURN principal.arn AS principal, policy.arn AS policy, - stmt.action AS actions, stmt.resource AS resources +MATCH (principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {effect: 'Allow'}) +MATCH (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) +WHERE a.value = '*' +RETURN DISTINCT principal.arn AS principal, policy.arn AS policy LIMIT 25 ``` @@ -173,218 +172,89 @@ RETURN r.name AS role_name, r.arn AS role_arn, p.arn AS trusted_service LIMIT 25 ``` -### Advanced Attack Path Scenarios +### Working with List-Typed Properties -The following scenarios show how to compose graph traversals into real attack-path stories. Each query can be pasted directly into the custom query box: the API auto-scopes them to the selected provider and injects tenant/provider isolation, so there is no need to include account identifiers or `$provider_uid` in the text. All queries are openCypher v9 (Neo4j and Neptune compatible). +Some Cartography node properties carry a list of values, such as `action`, `resource`, `notaction`, and `notresource` on `AWSPolicyStatement` nodes, the algorithms on `KMSKey`, the container-definition lists on `ECSContainerDefinition`, and many others. The Attack Paths graph models each such property as a set of child item nodes connected to the parent by a typed edge. To read the values, traverse the edge; the parent does not carry the list as a single field. -#### 1. Live attacker on the box that owns the keys +The naming convention for any list-typed property on a parent label is: -**Query story:** Finds an internet-exposed EC2 under an active GuardDuty SSH brute-force whose instance role can assume a higher-privileged role that can read a sensitive S3 bucket. +- **Child label:** `Item`. Example: `AWSPolicyStatement.resource` resolves to `AWSPolicyStatementResourceItem`. +- **Edge type:** `HAS_`. Example: `resource` resolves to `HAS_RESOURCE`. +- **Child property:** `value` for scalar lists (one string per list element). List-of-dict properties (rare; for example `SecretsManagerSecretVersion.tags`) carry the original dict keys as named fields on the child node. + +To express "at least one item in the list satisfies a predicate", traverse the `HAS_*` edge in its own `MATCH` clause and apply the predicate in the attached `WHERE`. `RETURN DISTINCT` collapses duplicate parent rows produced when multiple child items satisfy the filter: ```cypher -MATCH path_ec2 = (acct:AWSAccount)--(ec2:EC2Instance) -WHERE ec2.exposed_internet = true -MATCH p0 = (gd:GuardDutyFinding)-[:AFFECTS]->(ec2) -MATCH p1 = (ec2)-[:INSTANCE_PROFILE]->(prof:AWSInstanceProfile)-[:ASSOCIATED_WITH]->(low:AWSRole) -MATCH p2 = (low)-[:STS_ASSUMEROLE_ALLOW]-(high:AWSRole) -MATCH p3 = (high)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) -OPTIONAL MATCH path_net = (internet:Internet)-[:CAN_ACCESS]->(ec2) -MATCH path_s3 = (acct)--(s3:S3Bucket) -WHERE high <> low - AND stmt.effect = 'Allow' - AND size([a IN stmt.action WHERE - toLower(a) STARTS WITH 's3:getobject' - OR toLower(a) STARTS WITH 's3:listbucket' - OR toLower(a) IN ['s3:*'] - ]) > 0 - AND size([r IN stmt.resource WHERE - r CONTAINS s3.name - ]) > 0 -RETURN path_net, path_ec2, p0, p1, p2, p3, path_s3 -``` - -**How it's built:** - -- `path_ec2` anchors the graph on the account node and its internet-exposed EC2 instance, via a real account-to-resource edge. This is the visible spine that keeps everything connected. -- `p0` ties a `GuardDutyFinding` to that instance through the `AFFECTS` edge (the live SSH brute-force alert). -- `p1` walks the real graph edges from the instance to its instance profile to the role it runs as. -- `p2` follows the `STS_ASSUMEROLE_ALLOW` edge to the higher-privileged role the low role can assume. It is undirected so it works regardless of how the assume edge was ingested. `high <> low` stops a role matching itself. -- `p3` walks that role into its policy and policy statement. -- `path_net` is the optional `Internet -[:CAN_ACCESS]-> instance` edge. It makes "from the internet" literal on screen. Optional so a missing `Internet` node never breaks the query live. -- `path_s3` connects the sensitive bucket to the same account node, so it draws connected instead of floating. There is no physical edge from a role to a bucket; the grant is logical, enforced in the `WHERE`: the statement must allow an S3 read action (list comprehension over the `action` array) and its resource must cover the bucket (`CONTAINS s3.name`). The account is the shared hub; the bucket hanging off it next to the role chain is the teaching moment — the access exists only in IAM. - -#### 2. Who can read the crown jewels - -**Query story:** The sensitive bucket from the previous scenario seen from the data side: every role whose IAM policy can read it, regardless of how the role is reached. - -```cypher -MATCH (s3:S3Bucket) -WHERE toLower(s3.name) CONTAINS 'sensitive' -MATCH (role:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) -WHERE stmt.effect = 'Allow' - AND size([a IN stmt.action WHERE - toLower(a) STARTS WITH 's3:get' - OR toLower(a) STARTS WITH 's3:list' - OR toLower(a) IN ['s3:*'] - ]) > 0 - AND size([r IN stmt.resource WHERE - r CONTAINS s3.name - ]) > 0 -WITH DISTINCT s3, role +MATCH (stmt:AWSPolicyStatement {effect: 'Allow'}) +MATCH (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) +WHERE toLower(a.value) STARTS WITH 's3:get' + OR toLower(a.value) STARTS WITH 's3:list' +RETURN DISTINCT stmt LIMIT 25 -MATCH path_s3 = (acct:AWSAccount)--(s3) -MATCH path_role = (acct)--(role) -RETURN path_s3, path_role ``` -**How it's built:** data-centric, not attacker-centric — the same bucket the previous kill chain exfiltrates, approached from the other direction. - -- The `S3Bucket` is bound first by name (one node), so everything else filters against it. -- `(role:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)` reaches statements only *through a role*, never via a global statement scan. A blanket `AWSPolicyStatement` scan also hits resource-policy statements whose shape differs and makes the list comprehension fail outright. -- The `WHERE` filters in place: an S3 read action plus a resource that names that bucket. -- `WITH DISTINCT s3, role LIMIT 25` collapses undirected-traversal duplicates and hard-caps the result. -- `path_s3` and `path_role` attach the account hubs only after the cap, against at most 25 rows, so the bucket and role(s) draw connected through the account instead of floating. -- No internet or EC2 here; this answers "who has the keys" instead of "how would an attacker get in." - -#### 3. Lateral reach from an internet-exposed instance - -**Query story:** The wide-angle view of the live-attacker scenario: every internet-exposed EC2, the role it runs as, and every role that role can assume. The first scenario is one specific exfiltration path inside this reach, under live attack. +To check whether every item in the list satisfies a predicate, count the counter-examples and require zero, together with a guard that ensures at least one item is attached. This is the one case where the pattern-comprehension form is the right tool: ```cypher -MATCH path_ec2 = (acct:AWSAccount)--(ec2:EC2Instance) -WHERE ec2.exposed_internet = true -MATCH p1 = (ec2)-[:INSTANCE_PROFILE]->(prof:AWSInstanceProfile)-[:ASSOCIATED_WITH]->(low:AWSRole) -MATCH p2 = (low)-[:STS_ASSUMEROLE_ALLOW]-(high:AWSRole) -OPTIONAL MATCH path_net = (internet:Internet)-[:CAN_ACCESS]->(ec2) -WHERE high <> low -RETURN path_net, path_ec2, p1, p2 +MATCH (stmt:AWSPolicyStatement) +WHERE size([ + (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) + WHERE NOT toLower(a.value) STARTS WITH 's3:' + | a + ]) = 0 + AND size([(stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) | a]) > 0 +RETURN stmt +LIMIT 25 ``` -**How it's built:** widens the lens instead of filtering down. It stops at the assume-role hop and shows every role reachable from any internet-exposed instance, without filtering down to a specific S3 leg. - -- `path_ec2` is the account-to-instance spine. -- `p1` walks to the instance role. -- `p2` fans out to every role that role can assume. -- `path_net` adds the optional `Internet -[:CAN_ACCESS]->` edge. -- The first scenario is the specific exfiltration path under live attack; this is the broader privilege reach an attacker inherits the moment they land on the box. - -#### 4. Role-chain privilege escalation - -**Query story:** A pure-IAM escalation, no compromised instance: a role that can assume a second role whose policy lets it assume a third, admin-level role. +For the "is any item of this list a substring of a dynamic value" case, such as "does any resource pattern in this policy match a target role ARN", add the `HAS_*` traversal as its own `MATCH` and check the substring relationship between the item value and the dynamic node in `WHERE`: ```cypher -MATCH path_root = (acct:AWSAccount)--(r1:AWSRole) -MATCH p1 = (r1)-[:STS_ASSUMEROLE_ALLOW]-(r2:AWSRole) -MATCH p2 = (r2)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) -MATCH path_admin = (acct)--(admin:AWSRole) -WHERE r1 <> r2 AND r1 <> admin AND r2 <> admin - AND stmt.effect = 'Allow' - AND size([a IN stmt.action WHERE - toLower(a) IN ['sts:*', 'sts:assumerole'] - ]) > 0 - AND size([res IN stmt.resource WHERE - res CONTAINS admin.name - ]) > 0 -RETURN path_root, p1, p2, path_admin +MATCH (role:AWSRole) +WHERE role.name = 'Admin' +MATCH (principal:AWSPrincipal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {effect: 'Allow'}) +MATCH (stmt)-[:HAS_RESOURCE]->(r:AWSPolicyStatementResourceItem) +WHERE r.value = '*' + OR r.value CONTAINS role.name + OR role.arn CONTAINS r.value +RETURN DISTINCT principal.arn AS principal, stmt, role +LIMIT 25 ``` -**How it's built:** - -- `path_root` anchors role 1 to the account node, the spine that keeps the picture connected. -- `p1` is the one real assume edge in the chain (role 1 to role 2). -- `p2` walks role 2 into its policy and statement. -- `path_admin` connects the target admin role to the same account node so it draws connected. The third hop is not a graph edge: it exists only as `sts:AssumeRole` on that role's ARN inside the statement. The query proves it the same way the first scenario proves S3 access — the statement action must include an assume-role action and its resource list must reference the admin role's name. -- The three `<>` guards stop a role matching itself at any position. - -#### 5. External identity trust map - -**Query story:** Finds external identity providers (SSO, GitHub, GitLab, Terraform Cloud) and the AWS roles they are trusted to assume. +To return the list of values directly, collect them from the child items: ```cypher -MATCH p = (role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]-(idp:AWSPrincipal) -WHERE idp.arn CONTAINS 'saml-provider' - OR idp.arn CONTAINS 'oidc-provider' -MATCH path_role = (acct:AWSAccount)--(role) -RETURN p, path_role +MATCH (stmt:AWSPolicyStatement {effect: 'Allow'}) +OPTIONAL MATCH (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) +RETURN stmt, collect(a.value) AS actions +LIMIT 25 ``` -**How it's built:** federated principals are stored as `AWSPrincipal` nodes whose ARN contains `saml-provider` (SSO) or `oidc-provider` (GitHub, GitLab, Terraform Cloud). +### Working with JSON-Encoded Properties -- `p` matches the trust edge undirected. It is written `(AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(AWSPrincipal)`, role to principal, so a directed `principal -> role` match returns nothing; undirected matches regardless of ingest direction. -- The `WHERE` keeps only SAML or OIDC providers, drawing a fan-out from each external identity provider to every role it can assume (including reserved SSO admin roles). -- `path_role` ties every trusted role to the account node so the provider stars share one spine instead of drawing as separate islands. +Some Cartography properties represent nested objects, most notably `condition` on `AWSPolicyStatement` and `S3PolicyStatement` nodes. In the Attack Paths graph, object-typed properties are stored as JSON-encoded strings to keep the schema portable across graph backends. The value looks like: -#### 6. Federated SSO roles flagged as admin or privesc +``` +'{"StringEquals":{"aws:SourceAccount":"123456789012"}}' +``` -**Query story:** The dangerous subset of the trust map above — externally-federated SSO roles that Prowler also flags for AdministratorAccess or privilege escalation. +There is no JSON parser available at query time, so use `CONTAINS` for substring checks against keys or known values: ```cypher -MATCH (idp:AWSPrincipal)-[:TRUSTS_AWS_PRINCIPAL]-(role:AWSRole) -WHERE idp.arn CONTAINS 'saml-provider' - OR idp.arn CONTAINS 'oidc-provider' -MATCH (role)-[:HAS_FINDING]-(pf:ProwlerFinding) -WHERE pf.status = 'FAIL' - AND pf.check_id IN [ - 'iam_inline_policy_allows_privilege_escalation', - 'iam_role_administratoraccess_policy', - 'iam_inline_policy_no_administrative_privileges', - 'iam_user_administrator_access_policy' - ] -WITH DISTINCT idp, role, pf -LIMIT 60 -MATCH path_root = (acct:AWSAccount)--(role) -MATCH p_trust = (idp)-[:TRUSTS_AWS_PRINCIPAL]-(role) -MATCH p_find = (role)-[:HAS_FINDING]-(pf) -RETURN path_root, p_trust, p_find +MATCH (stmt:AWSPolicyStatement) +WHERE stmt.effect = 'Allow' + AND stmt.condition CONTAINS '"aws:SourceAccount"' +RETURN stmt +LIMIT 25 ``` -**How it's built:** a plain "list every flagged identity" query is a wide fan that draws as a column, and `ProwlerFinding` nodes accumulate across scans with no scan filter available in custom queries. - -- The first MATCH plus `WHERE` keeps only roles trusted by a SAML or OIDC provider (trust edge undirected, so direction does not matter). -- The second MATCH plus `check_id IN [...]` keeps only those carrying one of the four privilege-escalation or admin checks. -- `WITH DISTINCT ... LIMIT 60` collapses duplicate finding nodes and hard-caps the result. -- `p_trust`, `p_find`, and `path_root` draw it connected three ways: provider to role through the trust edge, role to its finding, and role to the account. -- The previous scenario shows who can walk in; this shows which of those roles Prowler already flags as over-privileged. - -#### 7. World-readable S3 buckets - -**Query story:** Unlike the IAM-gated sensitive bucket in scenarios 1 and 2, these buckets are open to anyone on the internet with no credentials at all. - -```cypher -MATCH path_s3 = (acct:AWSAccount)--(s3:S3Bucket) -WHERE s3.anonymous_access = true -OPTIONAL MATCH p = (s3)--(stmt:S3PolicyStatement) -RETURN path_s3, p -``` - -**How it's built:** the counterpoint to scenarios 1 and 2 — there the sensitive bucket is reachable only through an IAM role chain; here the bucket needs no role at all. - -- `path_s3` connects each public bucket to its account node so they draw connected. Cartography sets `anonymous_access = true` when a bucket's policy or ACL allows public access. -- `p` is an optional match that pulls in the `S3PolicyStatement` granting the access where one exists, so the public grant is visible next to the bucket. Buckets that are public via ACL only still show, connected to the account. - -#### 8. Internet exposure surface - -**Query story:** The raw external attack surface behind scenarios 1 and 3: every internet-exposed EC2 instance with its security groups and the exact inbound ports left open. - -```cypher -MATCH path_ec2 = (acct:AWSAccount)--(ec2:EC2Instance) -WHERE ec2.exposed_internet = true -MATCH p1 = (ec2)--(sg:EC2SecurityGroup)--(rule:IpPermissionInbound) -OPTIONAL MATCH path_net = (internet:Internet)-[:CAN_ACCESS]->(ec2) -OPTIONAL MATCH p2 = (ec2)-[:INSTANCE_PROFILE]->(:AWSInstanceProfile)-[:ASSOCIATED_WITH]->(:AWSRole) -RETURN path_net, path_ec2, p1, p2 -``` - -**How it's built:** `exposed_internet = true` is Cartography's computed reachability flag. - -- `path_ec2` hubs all exposed instances on the account node so they draw as one picture. -- `p1` joins each instance to its security groups and inbound rules so the open ports are on screen. -- `path_net` adds the optional `Internet -[:CAN_ACCESS]->` edge so the external reachability is explicit. -- `p2` optionally adds the instance role, which connects this surface view back to the kill chains in scenarios 1 and 3. +When a query needs to inspect the structured members of a condition (for example, evaluate every operator and key), fetch the rows first and parse the JSON in application code. Cypher cannot navigate JSON object keys or values. ### Tips for Writing Queries - Start small with `LIMIT` to inspect the shape of the data before broadening the pattern. +- Traverse `HAS_*` edges to reach list-typed property values (for example `action`, `resource`). The parent node does not carry the list as a single field; see [Working with List-Typed Properties](#working-with-list-typed-properties) for the patterns. +- On large scans, avoid broad disconnected patterns such as `MATCH (a:Label), (b:OtherLabel)`. Bind one side with a selective predicate first, and use `WITH DISTINCT` between expanding traversals when duplicates are possible. - Use `RETURN` projections (`RETURN n.name, n.region`) instead of returning whole nodes to keep responses compact. - Combine resource nodes with `ProwlerFinding` nodes via `HAS_FINDING` to correlate misconfigurations with the affected resources. - When a query times out or returns no rows, simplify the pattern step by step until the first variant runs successfully, then add constraints back. @@ -401,6 +271,8 @@ In addition to the upstream schema, Prowler enriches the graph with: - **`ProwlerFinding`** nodes representing Prowler check results, linked to affected resources via `HAS_FINDING` relationships. - **`Internet`** nodes used to model exposure paths from the public internet to internal resources. +- **List-typed properties** such as `action` or `resource` on `AWSPolicyStatement`, the algorithm lists on `KMSKey`, and similar lists on other node types are modeled as child item nodes linked by typed `HAS_*` edges. See [Working with List-Typed Properties](#working-with-list-typed-properties) for the read pattern. +- **Object-typed properties** such as `condition` on `AWSPolicyStatement` are stored as JSON-encoded strings. See [Working with JSON-Encoded Properties](#working-with-json-encoded-properties) for the read pattern. AI assistants connected through Prowler MCP Server can fetch the exact @@ -539,105 +411,106 @@ Attack Paths currently supports the following built-in queries for AWS: #### Custom Attack Path Queries -| Query | Description | -|---|---| +| Query | Description | +| ------------------------------------------------- | ---------------------------------------------------------------------------------------- | | **Internet-Exposed EC2 with Sensitive S3 Access** | Find SSH-exposed EC2 instances that can assume roles to read tagged sensitive S3 buckets | #### Basic Resource Queries -| Query | Description | -|---|---| -| **RDS Instances Inventory** | List all provisioned RDS database instances in the account | -| **Unencrypted RDS Instances** | Find RDS instances with storage encryption disabled | -| **S3 Buckets with Anonymous Access** | Find S3 buckets that allow anonymous access | -| **IAM Statements Allowing All Actions** | Find IAM policy statements that allow all actions via wildcard (\*) | -| **IAM Statements Allowing Policy Deletion** | Find IAM policy statements that allow iam:DeletePolicy | -| **IAM Statements Allowing Create Actions** | Find IAM policy statements that allow any create action | +| Query | Description | +| ------------------------------------------- | ------------------------------------------------------------------- | +| **RDS Instances Inventory** | List all provisioned RDS database instances in the account | +| **Unencrypted RDS Instances** | Find RDS instances with storage encryption disabled | +| **S3 Buckets with Anonymous Access** | Find S3 buckets that allow anonymous access | +| **IAM Statements Allowing All Actions** | Find IAM policy statements that allow all actions via wildcard (\*) | +| **IAM Statements Allowing Policy Deletion** | Find IAM policy statements that allow iam:DeletePolicy | +| **IAM Statements Allowing Create Actions** | Find IAM policy statements that allow any create action | #### Network Exposure Queries -| Query | Description | -|---|---| -| **Internet-Exposed EC2 Instances** | Find EC2 instances flagged as exposed to the internet | +| Query | Description | +| ----------------------------------------------------- | ----------------------------------------------------------------------------------- | +| **Internet-Exposed EC2 Instances** | Find EC2 instances flagged as exposed to the internet | | **Open Security Groups on Internet-Facing Resources** | Find internet-facing resources with security groups allowing inbound from 0.0.0.0/0 | -| **Internet-Exposed Classic Load Balancers** | Find Classic Load Balancers exposed to the internet with their listeners | -| **Internet-Exposed ALB/NLB Load Balancers** | Find ELBv2 (ALB/NLB) load balancers exposed to the internet with their listeners | -| **Resource Lookup by Public IP** | Find the AWS resource associated with a given public IP address | +| **Internet-Exposed Classic Load Balancers** | Find Classic Load Balancers exposed to the internet with their listeners | +| **Internet-Exposed ALB/NLB Load Balancers** | Find ELBv2 (ALB/NLB) load balancers exposed to the internet with their listeners | +| **Resource Lookup by Public IP** | Find the AWS resource associated with a given public IP address | #### Privilege Escalation Queries These queries are based on research from [pathfinding.cloud](https://pathfinding.cloud) by Datadog. -| Query | Description | -|---|---| -| **App Runner Service Creation with Privileged Role (APPRUNNER-001)** | Create an App Runner service with a privileged IAM role to gain its permissions | -| **App Runner Service Update for Role Access (APPRUNNER-002)** | Update an existing App Runner service to leverage its already-attached privileged role | -| **Bedrock Code Interpreter with Privileged Role (BEDROCK-001)** | Create a Bedrock AgentCore Code Interpreter with a privileged role attached | -| **Bedrock Code Interpreter Session Hijacking (BEDROCK-002)** | Start a session on an existing Bedrock code interpreter to exfiltrate its privileged role credentials | -| **CloudFormation Stack Creation with Privileged Role (CLOUDFORMATION-001)** | Create a CloudFormation stack with a privileged role to provision arbitrary AWS resources | -| **CloudFormation Stack Update for Role Access (CLOUDFORMATION-002)** | Update an existing CloudFormation stack to leverage its already-attached privileged service role | -| **CloudFormation StackSet Creation with Privileged Role (CLOUDFORMATION-003)** | Create a CloudFormation StackSet with a privileged execution role to provision arbitrary resources across accounts | -| **CloudFormation StackSet Update with Privileged Role (CLOUDFORMATION-004)** | Update an existing CloudFormation StackSet to inject malicious resources using a privileged execution role | -| **CloudFormation Change Set Privilege Escalation (CLOUDFORMATION-005)** | Create and execute a change set on an existing stack to leverage its privileged service role | -| **CodeBuild Project Creation with Privileged Role (CODEBUILD-001)** | Create a CodeBuild project with a privileged role to execute arbitrary code via a malicious buildspec | -| **CodeBuild Buildspec Override for Role Access (CODEBUILD-002)** | Start a build on an existing CodeBuild project with a buildspec override to execute code with its privileged role | -| **CodeBuild Batch Buildspec Override for Role Access (CODEBUILD-003)** | Start a batch build on an existing CodeBuild project with a buildspec override to execute code with its privileged role | -| **CodeBuild Batch Project Creation with Privileged Role (CODEBUILD-004)** | Create a CodeBuild project configured for batch builds with a privileged role to execute arbitrary code via a malicious buildspec | -| **Data Pipeline Creation with Privileged Role (DATAPIPELINE-001)** | Create a Data Pipeline with a privileged role to execute arbitrary commands on provisioned infrastructure | -| **EC2 Instance Launch with Privileged Role (EC2-001)** | Launch EC2 instances with privileged IAM roles to gain their permissions via IMDS | -| **EC2 Role Hijacking via UserData Injection (EC2-002)** | Inject malicious scripts into EC2 instance userData to gain the attached role's permissions | -| **Spot Instance Launch with Privileged Role (EC2-003)** | Launch EC2 Spot Instances with privileged IAM roles to gain their permissions via IMDS | -| **Launch Template Poisoning for Role Access (EC2-004)** | Inject malicious userData into launch templates that reference privileged roles, no PassRole needed | -| **EC2 Instance Connect SSH Access for Role Credentials (EC2INSTANCECONNECT-003)** | Push a temporary SSH key to an EC2 instance via Instance Connect to access its attached role credentials through IMDS | -| **ECS Service Creation with Privileged Role (ECS-001 - New Cluster)** | Create an ECS cluster and service with a privileged Fargate task role to execute arbitrary code | -| **ECS Task Execution with Privileged Role (ECS-002 - New Cluster)** | Create an ECS cluster and run a one-off Fargate task with a privileged role to execute arbitrary code | -| **ECS Service Creation with Privileged Role (ECS-003 - Existing Cluster)** | Deploy a Fargate service with a privileged role on an existing ECS cluster | -| **ECS Task Execution with Privileged Role (ECS-004 - Existing Cluster)** | Run a one-off Fargate task with a privileged role on an existing ECS cluster | -| **ECS Task Start with Privileged Role on EC2 (ECS-005 - Existing Cluster)** | Register a task definition with a privileged role and start it on an EC2 container instance to execute arbitrary code | -| **ECS Exec Container Hijacking for Role Credentials (ECS-006)** | Shell into a running ECS container via ECS Exec to steal the attached task role's credentials | -| **Glue Dev Endpoint with Privileged Role (GLUE-001)** | Create a Glue development endpoint with a privileged role attached to gain its permissions | -| **Glue Dev Endpoint SSH Hijacking via Update (GLUE-002)** | Update an existing Glue development endpoint to inject an SSH public key and access its attached role credentials | -| **Glue Job Creation with Privileged Role (GLUE-003)** | Create a Glue job with a privileged role and start it to execute arbitrary code with that role's permissions | -| **Glue Job Creation with Scheduled Trigger and Privileged Role (GLUE-004)** | Create a Glue job with a privileged role and a scheduled trigger to persistently execute arbitrary code | -| **Glue Job Hijacking via Update with Privileged Role (GLUE-005)** | Update an existing Glue job to attach a privileged role and inject malicious code, then start it to gain that role's permissions | -| **Glue Job Hijacking with Scheduled Trigger and Privileged Role (GLUE-006)** | Update an existing Glue job to attach a privileged role and inject malicious code, then create a scheduled trigger for persistent automated execution | -| **Policy Version Override for Self-Escalation (IAM-001)** | Create a new version of an attached policy with administrative permissions, instantly escalating the principal's own privileges | -| **Access Key Creation for Lateral Movement (IAM-002)** | Create access keys for other IAM users to gain their permissions and move laterally across the account | -| **Access Key Rotation Attack for Lateral Movement (IAM-003)** | Delete and recreate access keys for other IAM users to bypass the two-key limit and gain their permissions | -| **Console Login Profile Creation for Lateral Movement (IAM-004)** | Create console login profiles for other IAM users to access the AWS Console with their permissions | -| **Inline Policy Injection for Self-Escalation (IAM-005)** | Attach an inline policy with administrative permissions to your own role, instantly escalating privileges | -| **Console Password Override for Lateral Movement (IAM-006)** | Change the console password of other IAM users to log in as them and gain their permissions | -| **Inline Policy Injection on User for Self-Escalation (IAM-007)** | Attach an inline policy with administrative permissions to your own IAM user, instantly escalating privileges | -| **Managed Policy Attachment on User for Self-Escalation (IAM-008)** | Attach existing managed policies with administrative permissions to your own IAM user, instantly escalating privileges | -| **Managed Policy Attachment on Role for Self-Escalation (IAM-009)** | Attach existing managed policies with administrative permissions to your own IAM role, instantly escalating privileges | -| **Managed Policy Attachment on Group for Self-Escalation (IAM-010)** | Attach existing managed policies with administrative permissions to a group you belong to, escalating privileges for all group members | -| **Inline Policy Injection on Group for Self-Escalation (IAM-011)** | Attach an inline policy with administrative permissions to a group you belong to, escalating privileges for all group members | -| **Trust Policy Hijacking for Role Assumption (IAM-012)** | Modify a role's trust policy to allow yourself to assume it, gaining the role's permissions | -| **Group Membership Hijacking for Privilege Escalation (IAM-013)** | Add yourself to a privileged IAM group to inherit its permissions, gaining access to all policies attached to the group | -| **Managed Policy Attachment with Role Assumption for Lateral Movement (IAM-014)** | Attach administrative managed policies to another role you can assume, then assume it to gain elevated privileges | -| **Managed Policy Attachment with Access Key Creation for Lateral Movement (IAM-015)** | Attach administrative managed policies to another IAM user and create access keys for them to gain programmatic access with elevated privileges | -| **Policy Version Override with Role Assumption for Lateral Movement (IAM-016)** | Create a new version of a customer-managed policy attached to another role with administrative permissions, then assume that role to gain elevated access | -| **Inline Policy Injection with Role Assumption for Lateral Movement (IAM-017)** | Attach an inline policy with administrative permissions to another role you can assume, then assume it to gain elevated privileges | -| **Inline Policy Injection with Access Key Creation for Lateral Movement (IAM-018)** | Attach an inline policy with administrative permissions to another IAM user and create access keys for them to gain programmatic access with elevated privileges | -| **Managed Policy Attachment with Trust Policy Hijacking for Privilege Escalation (IAM-019)** | Attach administrative managed policies to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access | -| **Policy Version Override with Trust Policy Hijacking for Privilege Escalation (IAM-020)** | Create a new version of a customer-managed policy attached to a role with administrative permissions and modify its trust policy to assume it, without prior assume-role access | -| **Inline Policy Injection with Trust Policy Hijacking for Privilege Escalation (IAM-021)** | Add an inline policy with administrative permissions to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access | -| **Lambda Function Creation with Privileged Role (LAMBDA-001)** | Create a Lambda function with a privileged IAM role and invoke it to execute code with that role's permissions | -| **Lambda Function Creation with Event Source Trigger (LAMBDA-002)** | Create a Lambda function with a privileged IAM role and an event source mapping to trigger it automatically, executing code with the role's permissions | -| **Lambda Function Code Injection (LAMBDA-003)** | Modify the code of an existing Lambda function to execute arbitrary commands with the function's execution role permissions | -| **Lambda Function Code Injection with Direct Invocation (LAMBDA-004)** | Modify the code of an existing Lambda function and invoke it directly to execute arbitrary commands with the function's execution role permissions | -| **Lambda Function Code Injection with Resource Policy Grant (LAMBDA-005)** | Modify the code of an existing Lambda function and grant yourself invocation permission via its resource-based policy to execute code with the function's execution role | -| **Lambda Function Creation with Resource Policy Invocation (LAMBDA-006)** | Create a Lambda function with a privileged IAM role and grant yourself invocation permission via its resource-based policy to execute code with the role's permissions | -| **SageMaker Notebook Creation with Privileged Role (SAGEMAKER-001)** | Create a SageMaker notebook instance with a privileged IAM role to execute arbitrary code with the role's permissions via the Jupyter environment | -| **SageMaker Training Job Creation with Privileged Role (SAGEMAKER-002)** | Create a SageMaker training job with a privileged IAM role to execute arbitrary container code with the role's permissions | -| **SageMaker Processing Job Creation with Privileged Role (SAGEMAKER-003)** | Create a SageMaker processing job with a privileged IAM role to execute arbitrary container code with the role's permissions | -| **SageMaker Presigned Notebook URL for Privilege Escalation (SAGEMAKER-004)** | Generate a presigned URL to access an existing SageMaker notebook instance and execute code with its execution role's permissions | -| **SageMaker Notebook Lifecycle Config Injection (SAGEMAKER-005)** | Inject a malicious lifecycle configuration into an existing SageMaker notebook to execute code with the notebook's execution role during startup | -| **SSM Session Access for EC2 Role Credentials (SSM-001)** | Start an SSM session on an EC2 instance to access its attached role credentials through IMDS | -| **SSM Send Command for EC2 Role Credentials (SSM-002)** | Execute commands on an EC2 instance via SSM Run Command to access its attached role credentials through IMDS | -| **Role Assumption for Privilege Escalation (STS-001)** | Assume IAM roles with elevated permissions by exploiting bidirectional trust between the starting principal and the target role | +| Query | Description | +| -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **App Runner Service Creation with Privileged Role (APPRUNNER-001)** | Create an App Runner service with a privileged IAM role to gain its permissions | +| **App Runner Service Update for Role Access (APPRUNNER-002)** | Update an existing App Runner service to leverage its already-attached privileged role | +| **Bedrock Code Interpreter with Privileged Role (BEDROCK-001)** | Create a Bedrock AgentCore Code Interpreter with a privileged role attached | +| **Bedrock Code Interpreter Session Hijacking (BEDROCK-002)** | Start a session on an existing Bedrock code interpreter to exfiltrate its privileged role credentials | +| **CloudFormation Stack Creation with Privileged Role (CLOUDFORMATION-001)** | Create a CloudFormation stack with a privileged role to provision arbitrary AWS resources | +| **CloudFormation Stack Update for Role Access (CLOUDFORMATION-002)** | Update an existing CloudFormation stack to leverage its already-attached privileged service role | +| **CloudFormation StackSet Creation with Privileged Role (CLOUDFORMATION-003)** | Create a CloudFormation StackSet with a privileged execution role to provision arbitrary resources across accounts | +| **CloudFormation StackSet Update with Privileged Role (CLOUDFORMATION-004)** | Update an existing CloudFormation StackSet to inject malicious resources using a privileged execution role | +| **CloudFormation Change Set Privilege Escalation (CLOUDFORMATION-005)** | Create and execute a change set on an existing stack to leverage its privileged service role | +| **CodeBuild Project Creation with Privileged Role (CODEBUILD-001)** | Create a CodeBuild project with a privileged role to execute arbitrary code via a malicious buildspec | +| **CodeBuild Buildspec Override for Role Access (CODEBUILD-002)** | Start a build on an existing CodeBuild project with a buildspec override to execute code with its privileged role | +| **CodeBuild Batch Buildspec Override for Role Access (CODEBUILD-003)** | Start a batch build on an existing CodeBuild project with a buildspec override to execute code with its privileged role | +| **CodeBuild Batch Project Creation with Privileged Role (CODEBUILD-004)** | Create a CodeBuild project configured for batch builds with a privileged role to execute arbitrary code via a malicious buildspec | +| **Data Pipeline Creation with Privileged Role (DATAPIPELINE-001)** | Create a Data Pipeline with a privileged role to execute arbitrary commands on provisioned infrastructure | +| **EC2 Instance Launch with Privileged Role (EC2-001)** | Launch EC2 instances with privileged IAM roles to gain their permissions via IMDS | +| **EC2 Role Hijacking via UserData Injection (EC2-002)** | Inject malicious scripts into EC2 instance userData to gain the attached role's permissions | +| **Spot Instance Launch with Privileged Role (EC2-003)** | Launch EC2 Spot Instances with privileged IAM roles to gain their permissions via IMDS | +| **Launch Template Poisoning for Role Access (EC2-004)** | Inject malicious userData into launch templates that reference privileged roles, no PassRole needed | +| **EC2 Instance Connect SSH Access for Role Credentials (EC2INSTANCECONNECT-003)** | Push a temporary SSH key to an EC2 instance via Instance Connect to access its attached role credentials through IMDS | +| **ECS Service Creation with Privileged Role (ECS-001 - New Cluster)** | Create an ECS cluster and service with a privileged Fargate task role to execute arbitrary code | +| **ECS Task Execution with Privileged Role (ECS-002 - New Cluster)** | Create an ECS cluster and run a one-off Fargate task with a privileged role to execute arbitrary code | +| **ECS Service Creation with Privileged Role (ECS-003 - Existing Cluster)** | Deploy a Fargate service with a privileged role on an existing ECS cluster | +| **ECS Task Execution with Privileged Role (ECS-004 - Existing Cluster)** | Run a one-off Fargate task with a privileged role on an existing ECS cluster | +| **ECS Task Start with Privileged Role on EC2 (ECS-005 - Existing Cluster)** | Register a task definition with a privileged role and start it on an EC2 container instance to execute arbitrary code | +| **ECS Exec Container Hijacking for Role Credentials (ECS-006)** | Shell into a running ECS container via ECS Exec to steal the attached task role's credentials | +| **Glue Dev Endpoint with Privileged Role (GLUE-001)** | Create a Glue development endpoint with a privileged role attached to gain its permissions | +| **Glue Dev Endpoint SSH Hijacking via Update (GLUE-002)** | Update an existing Glue development endpoint to inject an SSH public key and access its attached role credentials | +| **Glue Job Creation with Privileged Role (GLUE-003)** | Create a Glue job with a privileged role and start it to execute arbitrary code with that role's permissions | +| **Glue Job Creation with Scheduled Trigger and Privileged Role (GLUE-004)** | Create a Glue job with a privileged role and a scheduled trigger to persistently execute arbitrary code | +| **Glue Job Hijacking via Update with Privileged Role (GLUE-005)** | Update an existing Glue job to attach a privileged role and inject malicious code, then start it to gain that role's permissions | +| **Glue Job Hijacking with Scheduled Trigger and Privileged Role (GLUE-006)** | Update an existing Glue job to attach a privileged role and inject malicious code, then create a scheduled trigger for persistent automated execution | +| **Policy Version Override for Self-Escalation (IAM-001)** | Create a new version of an attached policy with administrative permissions, instantly escalating the principal's own privileges | +| **Access Key Creation for Lateral Movement (IAM-002)** | Create access keys for other IAM users to gain their permissions and move laterally across the account | +| **Access Key Rotation Attack for Lateral Movement (IAM-003)** | Delete and recreate access keys for other IAM users to bypass the two-key limit and gain their permissions | +| **Console Login Profile Creation for Lateral Movement (IAM-004)** | Create console login profiles for other IAM users to access the AWS Console with their permissions | +| **Inline Policy Injection for Self-Escalation (IAM-005)** | Attach an inline policy with administrative permissions to your own role, instantly escalating privileges | +| **Console Password Override for Lateral Movement (IAM-006)** | Change the console password of other IAM users to log in as them and gain their permissions | +| **Inline Policy Injection on User for Self-Escalation (IAM-007)** | Attach an inline policy with administrative permissions to your own IAM user, instantly escalating privileges | +| **Managed Policy Attachment on User for Self-Escalation (IAM-008)** | Attach existing managed policies with administrative permissions to your own IAM user, instantly escalating privileges | +| **Managed Policy Attachment on Role for Self-Escalation (IAM-009)** | Attach existing managed policies with administrative permissions to your own IAM role, instantly escalating privileges | +| **Managed Policy Attachment on Group for Self-Escalation (IAM-010)** | Attach existing managed policies with administrative permissions to a group you belong to, escalating privileges for all group members | +| **Inline Policy Injection on Group for Self-Escalation (IAM-011)** | Attach an inline policy with administrative permissions to a group you belong to, escalating privileges for all group members | +| **Trust Policy Hijacking for Role Assumption (IAM-012)** | Modify a role's trust policy to allow yourself to assume it, gaining the role's permissions | +| **Group Membership Hijacking for Privilege Escalation (IAM-013)** | Add yourself to a privileged IAM group to inherit its permissions, gaining access to all policies attached to the group | +| **Managed Policy Attachment with Role Assumption for Lateral Movement (IAM-014)** | Attach administrative managed policies to another role you can assume, then assume it to gain elevated privileges | +| **Managed Policy Attachment with Access Key Creation for Lateral Movement (IAM-015)** | Attach administrative managed policies to another IAM user and create access keys for them to gain programmatic access with elevated privileges | +| **Policy Version Override with Role Assumption for Lateral Movement (IAM-016)** | Create a new version of a customer-managed policy attached to another role with administrative permissions, then assume that role to gain elevated access | +| **Inline Policy Injection with Role Assumption for Lateral Movement (IAM-017)** | Attach an inline policy with administrative permissions to another role you can assume, then assume it to gain elevated privileges | +| **Inline Policy Injection with Access Key Creation for Lateral Movement (IAM-018)** | Attach an inline policy with administrative permissions to another IAM user and create access keys for them to gain programmatic access with elevated privileges | +| **Managed Policy Attachment with Trust Policy Hijacking for Privilege Escalation (IAM-019)** | Attach administrative managed policies to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access | +| **Policy Version Override with Trust Policy Hijacking for Privilege Escalation (IAM-020)** | Create a new version of a customer-managed policy attached to a role with administrative permissions and modify its trust policy to assume it, without prior assume-role access | +| **Inline Policy Injection with Trust Policy Hijacking for Privilege Escalation (IAM-021)** | Add an inline policy with administrative permissions to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access | +| **Lambda Function Creation with Privileged Role (LAMBDA-001)** | Create a Lambda function with a privileged IAM role and invoke it to execute code with that role's permissions | +| **Lambda Function Creation with Event Source Trigger (LAMBDA-002)** | Create a Lambda function with a privileged IAM role and an event source mapping to trigger it automatically, executing code with the role's permissions | +| **Lambda Function Code Injection (LAMBDA-003)** | Modify the code of an existing Lambda function to execute arbitrary commands with the function's execution role permissions | +| **Lambda Function Code Injection with Direct Invocation (LAMBDA-004)** | Modify the code of an existing Lambda function and invoke it directly to execute arbitrary commands with the function's execution role permissions | +| **Lambda Function Code Injection with Resource Policy Grant (LAMBDA-005)** | Modify the code of an existing Lambda function and grant yourself invocation permission via its resource-based policy to execute code with the function's execution role | +| **Lambda Function Creation with Resource Policy Invocation (LAMBDA-006)** | Create a Lambda function with a privileged IAM role and grant yourself invocation permission via its resource-based policy to execute code with the role's permissions | +| **SageMaker Notebook Creation with Privileged Role (SAGEMAKER-001)** | Create a SageMaker notebook instance with a privileged IAM role to execute arbitrary code with the role's permissions via the Jupyter environment | +| **SageMaker Training Job Creation with Privileged Role (SAGEMAKER-002)** | Create a SageMaker training job with a privileged IAM role to execute arbitrary container code with the role's permissions | +| **SageMaker Processing Job Creation with Privileged Role (SAGEMAKER-003)** | Create a SageMaker processing job with a privileged IAM role to execute arbitrary container code with the role's permissions | +| **SageMaker Presigned Notebook URL for Privilege Escalation (SAGEMAKER-004)** | Generate a presigned URL to access an existing SageMaker notebook instance and execute code with its execution role's permissions | +| **SageMaker Notebook Lifecycle Config Injection (SAGEMAKER-005)** | Inject a malicious lifecycle configuration into an existing SageMaker notebook to execute code with the notebook's execution role during startup | +| **SSM Session Access for EC2 Role Credentials (SSM-001)** | Start an SSM session on an EC2 instance to access its attached role credentials through IMDS | +| **SSM Send Command for EC2 Role Credentials (SSM-002)** | Execute commands on an EC2 instance via SSM Run Command to access its attached role credentials through IMDS | +| **Role Assumption for Privilege Escalation (STS-001)** | Assume IAM roles with elevated permissions by exploiting bidirectional trust between the starting principal and the target role | These tools enable workflows such as: + - Asking an AI assistant to identify privilege escalation paths in a specific AWS account - Automating attack path analysis across multiple scans - Combining attack path data with findings and compliance information for comprehensive security reports diff --git a/docs/user-guide/tutorials/prowler-app-findings-triage.mdx b/docs/user-guide/tutorials/prowler-app-findings-triage.mdx new file mode 100644 index 0000000000..68b1af5a32 --- /dev/null +++ b/docs/user-guide/tutorials/prowler-app-findings-triage.mdx @@ -0,0 +1,124 @@ +--- +title: "Findings Triage" +description: "Track finding review status and team notes in Prowler Cloud." +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" +import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" + + + +Findings Triage lets teams track review status and notes for individual findings in Prowler Cloud. Use it to record investigation state, remediation work, accepted risk, or false positive decisions without leaving the Findings workflow. + + + +## What Is Findings Triage? + +Findings Triage adds a **Triage** status and team note workflow to individual finding rows. It is available from: + +- Expanded rows in **Finding Groups** +- Standalone finding tables +- Finding and resource detail drawers, including related findings tables + +Finding Groups rows do not show triage controls because a group row represents several findings. Expand a group to work with each affected resource. + +![Findings Triage Table](/images/prowler-app/findings-triage/findings-triage-table.png) + +## Required Permissions + +To update triage statuses and notes, the user role must have the **Manage Scans** permission. For more information, see [Role-Based Access Control (RBAC)](/user-guide/tutorials/prowler-app-rbac). + +Users without this permission can still see existing triage context when it is available, but cannot change statuses or save notes. + +## Triage Statuses + +The status selector includes manual statuses. Prowler also sets automatic statuses after scans. + +| Status | Type | Use It When | +| --- | --- | --- | +| **Open** | Manual | A failed finding has not been reviewed yet. A failed finding with no saved triage state also appears as **Open**. | +| **Under Review** | Manual | A team is investigating the finding. | +| **Remediating** | Manual | Work is in progress to fix the finding. | +| **Risk Accepted** | Manual | The team accepts the risk and wants to mute the finding. | +| **False Positive** | Manual | The finding does not apply and should be muted. | +| **Resolved** | Automatic | A finding changed from `FAIL` to `PASS` in a later scan. A passed finding with no saved triage state also appears as **Resolved**. | +| **Reopened** | Automatic | A finding changed from `PASS` to `FAIL` in a later scan. | + +![Findings Triage Status Selector](/images/prowler-app/findings-triage/findings-triage-status-dropdown.png) + +Resolved and Reopened are not manual selector options. + +These automatic states keep triage tied to the finding UID across scans, even when each scan creates a new finding snapshot. + +## Change a Triage Status + + + + Go to **Findings** in Prowler Cloud. + + + Expand a Finding Group, open a resource findings table, or use a standalone finding row. + + + In the **Triage** column, click the current status. + + + Select **Open**, **Under Review**, **Remediating**, **Risk Accepted**, or **False Positive**. + + + +Changing a finding to **Risk Accepted** or **False Positive** will mute the finding. Prowler asks for confirmation and creates a mute rule for the finding. + +## Add or Edit a Triage Note + +Triage notes are visible only to the team in the current organization. Each note supports up to 500 characters. + + + + On an individual finding row, click the actions menu. + + + Click **Add Triage Note**. If a note already exists, click **Open note**. + + + Optionally change the status, then write the note. + + + Click **Save changes**. + + + +![Findings Triage Note Modal](/images/prowler-app/findings-triage/findings-triage-note-modal.png) + +To remove an existing note, clear the note text and save the change. + +## Mutelist Behavior + +Findings Triage uses Mutelist when a status means the finding should be muted: + +- **Risk Accepted** creates a mute rule because the team accepts the finding as a known risk. +- **False Positive** creates a mute rule because the finding should not count as an active issue. + +Use [Simple Mutelist](/user-guide/tutorials/prowler-app-simple-mutelist) to review, disable, or delete mute rules created through this workflow. For pattern-based muting, use [Advanced Mutelist](/user-guide/tutorials/prowler-app-mute-findings). + + +Muting a finding does not fix the underlying configuration. Review the finding before using **Risk Accepted** or **False Positive**. + + +## Troubleshooting + +### Triage controls do not appear + +Make sure the row is an individual finding row. Finding Groups rows do not show triage controls. Expand a group to see affected resources and their triage controls. + +### Changes cannot be saved + +Confirm that the user role has **Manage Scans** permission. Self-hosted Prowler App does not support Findings Triage writes. + +### Resolved or Reopened is missing from the selector + +This is expected. Prowler sets **Resolved** and **Reopened** automatically from scan result changes. + +### Risk Accepted or False Positive muted a finding + +This is expected. Those statuses create a mute rule through Mutelist. diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx index cabea5bd35..c201482c6a 100644 --- a/docs/user-guide/tutorials/prowler-app-rbac.mdx +++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx @@ -226,8 +226,8 @@ Assign administrative permissions by selecting from the following options: |------------|-------|-------------| | Invite and Manage Users | All | Invite new users and manage existing ones. | | Manage Account | All | Adjust account settings, delete users and read/manage users permissions. | -| Manage Scans | All | Run and review scans. | -| Manage Providers | All | Add or modify connected providers. | +| Manage Scans | All | Run and review scans, and manage [Scan Configuration](/user-guide/tutorials/prowler-app-scan-configuration) settings. | +| Manage Providers | All | Add or modify connected providers, and attach or detach providers from a [Scan Configuration](/user-guide/tutorials/prowler-app-scan-configuration) (in addition to Manage Scans). | | Manage Integrations | All | Add or modify the Prowler Integrations. | | Manage Ingestions | Prowler Cloud | Allow or deny the ability to submit findings ingestion batches via the API. | | Manage Billing | Prowler Cloud | Access and manage billing settings and subscription information. | diff --git a/docs/user-guide/tutorials/prowler-app-scan-configuration.mdx b/docs/user-guide/tutorials/prowler-app-scan-configuration.mdx new file mode 100644 index 0000000000..feb52f2654 --- /dev/null +++ b/docs/user-guide/tutorials/prowler-app-scan-configuration.mdx @@ -0,0 +1,210 @@ +--- +title: 'Scan Configuration' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" +import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" + + + +Scan Configuration lets you override, per provider, specific values in the default configuration Prowler's checks use during a scan. Each configuration modifies how specific checks behave, e.g.: thresholds, allowed values, retention windows, and you attach it to the providers that you want to use it on their next scan. + + + +## What Is a Scan Configuration? + +A Scan Configuration lets you **override specific values, per provider**, on top of Prowler's defaults. It's merged with those defaults, not a full replacement: + +- A provider type you don't add a section for keeps using `config.yaml` untouched. +- **A provider type you do add a section for has its keys merged over `config.yaml` for that provider.** Only the keys you set are overridden; every key you leave out keeps its default from `config.yaml`, because each check falls back to the default configuration when a value isn't provided. See [How It's Applied](#how-its-applied) for details. +- It is stored per organization and applied to the **providers you attach** to it. +- **Attaching a provider is optional at creation time.** You can save a Scan Configuration with no providers attached and associate them later, either from the configuration's editor or from the **Providers** page (see [Attaching Providers](#attaching-providers)). It has no effect on any scan until at least one provider is attached. +- A provider can be attached to **at most one** Scan Configuration at a time. +- Changes take effect on the provider's **next scan** and do not re-run past scans. + + +The full set of configurable values and their defaults lives in [`prowler/config/config.yaml`](https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml). For what each key means and which checks read it, see the [Configuration File tutorial](/user-guide/cli/tutorials/configuration_file). + +## Required Permissions + +Scan Configuration access is governed by Role-Based Access Control (RBAC). See [RBAC Administrative Permissions](/user-guide/tutorials/prowler-app-rbac#rbac-administrative-permissions) for details on each permission. + +- **Viewing** a Scan Configuration doesn't require any specific permission. +- **Creating, editing, and deleting** a Scan Configuration requires the **Manage Scans** permission. +- **Attaching or detaching providers** requires the **Manage Providers** permission as well, in addition to Manage Scans. This applies to explicit changes to the attached providers, and to deleting a Scan Configuration that still has providers attached (deleting it detaches them too). +- Attaching or detaching a provider also requires that provider to be **visible to your role**. Visibility comes from the [Provider Groups](/user-guide/tutorials/prowler-app-rbac#provider-groups) assigned to your role, or from **Unlimited Visibility**. You can't attach a provider you can't see, and you can't detach one either, whether by removing it from the list or by deleting the Scan Configuration it's attached to. + +## Config Schema + +The YAML follows the structure of `config.yaml`: a mapping keyed by provider, with each provider section holding the keys you want to change. You only list the keys you want to override; they're merged over that provider's `config.yaml` defaults. + +```yaml +aws: + max_unused_access_keys_days: 30 + max_console_access_days: 30 + max_security_group_rules: 25 + +azure: + defender_attack_path_minimal_risk_level: "Critical" + +gcp: + storage_min_retention_days: 30 +``` + +## Creating a Scan Configuration + + + + On the **Scan** page (under **Configuration**), click **New Scan Configuration**. + + + Give the configuration a descriptive **Name** (3–100 characters), e.g. `stricter-iam-aws`. + + + In the **Configuration (YAML)** field, add the keys you want to change, grouped by provider. Only the keys you set are overridden; every other key keeps its `config.yaml` default. The editor is pre-filled with a representative default placeholder you can use as a starting point. + + + Under **Attach to providers**, pick the providers that should use this configuration. This is optional, you can save without any provider and attach them later, either by editing this configuration or from the **Providers** page (see [Attaching Providers](#attaching-providers)). + + + Click **Save**. The server validates the configuration values and, if everything is valid, stores it and attaches the selected providers. + + + + +You don't need to fill in every provider. A section you don't include leaves that provider's `config.yaml` untouched. Within a section you do include, only add the keys you want to change; the rest keep their `config.yaml` defaults. The placeholder shown in the editor is just an example; if you leave the field with only the placeholder (greyed-out) text, nothing is saved. + + +## How Validation Works + +Prowler checks your configuration in two stages, the same way the Advanced Mutelist editor does: + +1. **As you type: is it well-formed?** The editor checks that what you've written is valid YAML. If something is off, you'll see an `Invalid YAML format` message and **Save** stays disabled until you fix it. Once it's clean, it shows **Valid YAML format**. +2. **When you save: are the values allowed?** Saving checks that each value is one Prowler accepts, the right type, within range, and one of the allowed options where a key only takes a fixed set of choices. If a value isn't allowed, the editor points to the exact key and explains why, right beneath the field, so you can correct it and save again. + +For example, `azure.defender_attack_path_minimal_risk_level` only accepts `Low`, `Medium`, `High`, or `Critical`. Saving any other value returns an inline error like: + +``` +azure.defender_attack_path_minimal_risk_level: Input should be 'Low', 'Medium', 'High' or 'Critical' +``` + + +**Valid YAML format** only means the text is well-formed; it doesn't mean your values are accepted. Those are checked when you save. + +Be careful with indentation. A line like `azure: defender_attack_path_minimal_risk_level: Critical` (no line break and indent after `azure:`) is still valid YAML, but Prowler reads it as one long key name instead of a setting inside the `azure` section, so the value is silently ignored. Always nest provider keys: + +```yaml +azure: + defender_attack_path_minimal_risk_level: "Critical" +``` + + + +Prowler won't flag a section or key it doesn't recognize. It accepts them without error, so custom checks and plugins can add their own settings. The trade-off: a typo in a key or section name isn't rejected either, and that misspelled setting simply won't apply. Double-check your spelling against `config.yaml`. + + +## Attaching Providers + +A Scan Configuration only has an effect once it's attached to one or more providers. There are two ways to manage attachments. + +### From the Scan Config Editor + +In the **Attach to providers** field, select the providers that should use this configuration. Providers already attached to **another** configuration are hidden from the selector, since each provider can belong to only one configuration at a time. + +### From the Provider's Row Menu + +You can also manage a provider's configuration from **Providers**: + + + + On the **Providers** page, open the **⋮** menu on a provider row. + + + + + In the dialog, pick a configuration from the dropdown to apply it (picking a different one moves the provider), or pick **Default** to detach it and go back to the built-in `config.yaml` defaults. Then click **Save**. + + + + +This dialog only **associates or disassociates** an existing configuration. To create or edit the configuration's YAML, use the **Scan Config** view (a link is provided in the dialog). + + + +Because a provider can belong to only one configuration, associating a provider that is already attached elsewhere **moves** it to the new configuration automatically; it is removed from the previous one. + + +## Editing and Deleting + +On the **Scan Config** page, open the **⋮** menu on a configuration row: + +- **Edit:** Choose **Edit** to open the editor, change its name, YAML, or attached providers, and click **Update**. Editing the YAML always happens here, never from the provider row. +- **Delete:** Choose **Delete** (in the danger zone) and confirm. Providers that were attached fall back to the built-in defaults from `config.yaml` on their next scan. + +## How It's Applied + +When a scan runs for a provider: + +1. If the provider is attached to a Scan Configuration **and that configuration has a section for the provider's type** (e.g. `aws` for an AWS provider), Prowler merges that section over `config.yaml` for that provider's scan: the keys you set win, and every key you didn't set keeps its `config.yaml` default. +2. If the provider is attached to a Scan Configuration, but that configuration **has no section for the provider's type** (for example, a configuration that only defines an `aws` section, attached to a GCP provider), the scan uses the built-in defaults from `config.yaml` for that provider, exactly as if no Scan Configuration were attached at all. +3. If the provider isn't attached to any Scan Configuration, the built-in defaults from `config.yaml` are used. + + +The merge is per key. A key you don't set keeps its `config.yaml` default, because each check falls back to the default configuration when a value isn't provided. You only need to list the keys you want to change. + + + +A single Scan Configuration can hold sections for several provider types at once (see [Config Schema](#config-schema)) and be attached to providers of different types. Each provider only ever picks up the section matching its own type; the rest of the YAML is ignored for that provider. + + +## Effect on Compliance Results + +Some compliance requirements only hold if the checks they map to ran with a strict-enough configuration. For example, a requirement expecting unused access keys to be disabled within 45 days loses its meaning if a Scan Configuration raises `max_unused_access_keys_days` to 120: the check would still PASS, but the requirement wouldn't really be met. + +When a scan's applied configuration doesn't meet a requirement's expectations, Prowler marks that requirement as **FAIL** on the Compliance page, even if every individual finding passed. The requirement shows an info icon (and, when expanded, an inline alert) with: + +> Marked as FAIL because the applied scan configuration does not meet this requirement, even though all findings passed. + +This only affects requirements built around a configurable check that declares this kind of expectation; requirements without one are never affected by an attached Scan Configuration. + +## Common Examples + +Each example below shows only the keys being changed for that provider. + + +Only the keys shown are overridden. Every other key for that provider keeps its `config.yaml` default (see [How It's Applied](#how-its-applied)). + + +**Stricter IAM (Identity and Access Management) hygiene for AWS:** + +```yaml +aws: + max_unused_access_keys_days: 30 + max_console_access_days: 30 + max_unused_sagemaker_access_days: 45 +``` + +**Raise Azure Defender attack-path sensitivity:** + +```yaml +azure: + defender_attack_path_minimal_risk_level: "Critical" +``` + +**Tighten GCP storage retention and key rotation:** + +```yaml +gcp: + storage_min_retention_days: 30 + secretmanager_max_rotation_days: 30 +``` + +## Troubleshooting + +### A Provider Doesn't Appear in the Selector + +The provider is already attached to another Scan Configuration. Detach it there first, or use the provider row menu to move it. + +### You Can't Attach or Detach Providers + +If you can edit the configuration but not change its providers, or an error mentions a provider ID that "wasn't found", you're missing the **Manage Providers** permission or the provider isn't visible to your role. A provider outside your visibility is reported the same way as one that doesn't exist, so it isn't revealed to roles that shouldn't see it. See [Required Permissions](#required-permissions). diff --git a/docs/user-guide/tutorials/prowler-app-sso.mdx b/docs/user-guide/tutorials/prowler-app-sso.mdx index 1e61ec1060..d016196214 100644 --- a/docs/user-guide/tutorials/prowler-app-sso.mdx +++ b/docs/user-guide/tutorials/prowler-app-sso.mdx @@ -98,6 +98,12 @@ Choose a Method: + **Single-Value `userType` Required** + + Map `userType` to an IdP attribute that always contains a single value. If the IdP sends multiple values, Prowler App uses only the first value and does not assign multiple roles or select the highest-privilege role. + + + **Dynamic Updates** Prowler App updates these attributes each time a user logs in. Any changes made in the Identity Provider (IdP) will be reflected when the user logs in again. @@ -154,6 +160,7 @@ Choose a Method: * 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 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. + * `userType` must contain a single value. If the IdP sends multiple values, Prowler App uses only the first value and does not assign multiple roles. **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/docs/user-guide/tutorials/prowler-app.mdx b/docs/user-guide/tutorials/prowler-app.mdx index 5e99a41ae7..19bd48ee60 100644 --- a/docs/user-guide/tutorials/prowler-app.mdx +++ b/docs/user-guide/tutorials/prowler-app.mdx @@ -18,17 +18,14 @@ After [installing](/getting-started/installation/prowler-app) **Prowler App**, a To view the auto-generated **Prowler API** documentation, navigate to [http://localhost:8080/api/v1/docs](http://localhost:8080/api/v1/docs). This documentation provides details on available endpoints, parameters, and responses. -## **Step 1: Sign Up** - -### **Sign Up with Email** - +## Step 1: Sign Up +### Sign Up with Email To get started, sign up using your email and password: Sign Up Button Sign Up -### **Sign Up with Social Login** - +### Sign Up with Social Login If Social Login is enabled, you can sign up using your preferred provider (e.g., Google, GitHub). @@ -44,16 +41,14 @@ If your email is not registered, a new account will be created using your social See [how to configure Social Login for Prowler](/user-guide/tutorials/prowler-app-social-login) to enable this feature in your own deployments. -## **Step 2: Log In** - +## Step 2: Log In Once registered, log in with your email and password to access Prowler App. Log In Upon logging in, the Overview page will display. At this stage, no data is present: add a provider to begin scanning your cloud environment. -## **Step 3: Add a Provider** - +## Step 3: Add a Provider To perform security scans, link a cloud provider account. Prowler supports the following providers and more: - **AWS** @@ -77,8 +72,7 @@ Steps to add a provider: Add Provider -## **Step 4: Configure the Provider** - +## Step 4: Configure the Provider Select the cloud provider to scan and configure authentication credentials. Each provider has specific requirements and authentication methods. Select a Provider @@ -111,14 +105,12 @@ For detailed instructions on configuring credentials for each provider, refer to Scan IaC public or private repositories for security issues. -## **Step 5: Test Connection** - +## Step 5: Test Connection After adding your credentials of your cloud account, click the `Launch` button to verify that Prowler App can successfully connect to your provider: Test Connection -## **Step 6: Scan started** - +## Step 6: Scan Started After successfully adding and testing your credentials, Prowler will start scanning your cloud environment, click the `Go to Scans` button to see the progress: Start Now @@ -127,8 +119,7 @@ After successfully adding and testing your credentials, Prowler will start scann Prowler will automatically scan all configured providers every **24 hours**, ensuring your cloud environment stays continuously monitored. -## **Step 7: Monitor Scan Progress** - +## Step 7: Monitor Scan Progress Track the progress of your scan in the `Scans` section: Scan Progress @@ -146,8 +137,7 @@ Each dashboard handles scan data differently: When a new scan completes or a new data ingestion is processed, the dashboards automatically reflect the updated results. -## **Step 8: Analyze the Findings** - +## Step 8: Analyze the Findings While the scan is running, start exploring the findings in these sections: - **Overview**: High-level summary of the scans. @@ -168,8 +158,7 @@ While the scan is running, start exploring the findings in these sections: To view all `new` findings that have not been seen prior to this scan, click the `Delta` filter and select `new`. To view all `changed` findings that have had a status change (from `PASS` to `FAIL` for example), click the `Delta` filter and select `changed`. -## **Step 9: Download the Outputs** - +## Step 9: Download the Outputs Once a scan is complete, navigate to the Scan Jobs section to download the output files generated by Prowler: Scan Jobs section @@ -190,8 +179,7 @@ The `zip` file unpacks into a folder named like `prowler-output-- -## **Step 10: Download specified compliance report** - +## Step 10: Download Specified Compliance Report Once your scan has finished, you don’t need to grab the entire ZIP—just pull down the specific compliance report you want: - Navigate to the **Compliance** section of the UI. diff --git a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx index d9c17aaa5f..2a6128e92e 100644 --- a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx +++ b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx @@ -4,14 +4,15 @@ description: 'Onboard all AWS accounts in your Organization through a single gui --- import { VersionBadge } from "/snippets/version-badge.mdx" +import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" Prowler Cloud enables you to onboard all AWS accounts in your Organization through a single guided wizard. Instead of connecting accounts one by one, you can discover every account in your AWS Organization, select the ones you want to monitor, test connectivity, and launch scans — all from the Prowler Cloud UI. - -This feature is **exclusively available in Prowler Cloud**. For CLI-based multi-account scanning, see [AWS Organizations in Prowler CLI](/user-guide/providers/aws/organizations). - + +For CLI-based multi-account scanning, see [AWS Organizations in Prowler CLI](/user-guide/providers/aws/organizations). + ## Overview @@ -22,9 +23,9 @@ This feature is **exclusively available in Prowler Cloud**. For CLI-based multi- | **Individual accounts** | A few AWS accounts | Connect each account one by one with its own IAM role. | | **AWS Organizations** | 10+ accounts, or any org-managed environment | Connect once to your management account, discover all member accounts automatically, and scan them in bulk. | -### How it works +### How It Works -Before using the AWS Organizations wizard, you need to deploy **two IAM roles** in your AWS environment. The onboarding follows this sequence: +Before using the AWS Organizations wizard, you need to deploy **two Identity and Access Management (IAM) roles** in your AWS environment. The onboarding follows this sequence: Onboarding flow: 1. Create Management Account Role (Quick Create or Manual), 2. Deploy StackSet, 3. Run the Wizard, 4. Launch Scans @@ -32,7 +33,7 @@ Before using the AWS Organizations wizard, you need to deploy **two IAM roles** ## Key Concepts -### What is an External ID? +### What Is an External ID? An **External ID** is a security token that Prowler generates unique to your tenant. When Prowler assumes the IAM role in your AWS account, it presents this External ID to prove its identity. @@ -57,7 +58,7 @@ Prowler requires **two separate IAM roles** deployed in different places, each w **Same name, different permissions.** Both roles are named `ProwlerScan` — Prowler expects a consistent role name across all accounts. The management account role has the same scanning permissions as member accounts, plus additional Organizations discovery permissions (see [Step 1](#step-1-create-the-management-account-role) for the full list). -### What is a CloudFormation StackSet? +### What Is a CloudFormation StackSet? A [CloudFormation StackSet](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/what-is-cfnstacksets.html) lets you deploy the same CloudFormation template across multiple AWS accounts in a single operation. Prowler uses a StackSet to deploy the **ProwlerScan** IAM role into every member account of your organization, so you don't have to create the role manually in each account. @@ -437,14 +438,11 @@ If connection tests fail, here's how to fix common issues: ### Choose Scan Schedule -| Schedule Option | Description | -|-----------------|-------------| -| **Scan Daily (every 24 hours)** | Creates a recurring daily scan for all connected accounts (default). | -| **Run a single scan (no recurring schedule)** | Launches a one-time scan. | +The Organizations wizard uses the same schedule controls described in [Scan Scheduling](/user-guide/tutorials/prowler-scan-scheduling#schedule-options). ### Launch -Click **Launch scan**. A toast notification confirms: *"Scan Launched — Daily scan scheduled for X accounts"* with a link to the Scans page. You will be redirected to the **Providers** page. +Click **Save**, **Save and launch scan**, or **Launch scan**, depending on the selected schedule option. A toast notification confirms whether the schedule was saved, scans were launched, or both. The toast includes a link to the **Scans** page. Prowler redirects to the **Providers** page. Scans are only launched for accounts that are accessible (passed connection testing) and were selected. diff --git a/docs/user-guide/tutorials/prowler-import-findings.mdx b/docs/user-guide/tutorials/prowler-import-findings.mdx index d4c8f8a74d..e02f3ee7a5 100644 --- a/docs/user-guide/tutorials/prowler-import-findings.mdx +++ b/docs/user-guide/tutorials/prowler-import-findings.mdx @@ -4,16 +4,15 @@ description: 'Upload OCSF scan results to Prowler Cloud from external sources or --- import { VersionBadge } from "/snippets/version-badge.mdx" +import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" Findings Ingestion enables uploading OCSF (Open Cybersecurity Schema Framework) scan results to Prowler Cloud. This feature supports importing findings from Prowler CLI output files that use the [Detection Finding](https://schema.ocsf.io/classes/detection_finding) class. - -This feature is available exclusively in **Prowler Cloud** and **Prowler Enterprise** with a [paid subscription](https://prowler.com/pricing). - + -## OCSF Detection Finding format +## OCSF Detection Finding Format The ingestion API accepts `.ocsf.json` files containing a JSON array of OCSF Detection Finding records. Each finding represents a security check result from Prowler. @@ -130,7 +129,7 @@ The ingestion API accepts `.ocsf.json` files containing a JSON array of OCSF Det Only **Detection Finding** (`class_uid: 2004`) records are accepted. Other OCSF classes are not supported for ingestion. -## Required permissions +## Required Permissions The **Manage Ingestions** RBAC permission controls access to the ingestion endpoints. Without this permission, findings cannot be submitted via the API or `--push-to-cloud`. @@ -145,7 +144,7 @@ The `--push-to-cloud` flag uploads scan results directly to Prowler Cloud after - A valid Prowler Cloud API key (see [API Keys](/user-guide/tutorials/prowler-app-api-keys)) - The `PROWLER_CLOUD_API_KEY` environment variable configured -### Basic usage +### Basic Usage ```bash export PROWLER_CLOUD_API_KEY="pk_your_api_key_here" @@ -153,7 +152,7 @@ export PROWLER_CLOUD_API_KEY="pk_your_api_key_here" prowler aws --push-to-cloud ``` -### Combining with output formats +### Combining with Output Formats When using `--push-to-cloud` with custom output formats that exclude OCSF, Prowler generates a temporary OCSF file for upload: @@ -169,7 +168,7 @@ When default output formats include OCSF, Prowler reuses the existing file. Defa prowler aws --services accessanalyzer --push-to-cloud -o /tmp/scan-output ``` -### CLI output examples +### CLI Output Examples **Successful upload:** ``` @@ -228,7 +227,7 @@ curl -X POST \ https://api.prowler.com/api/v1/ingestions ``` -### Submit an ingestion batch +### Submit an Ingestion Batch Upload a `.ocsf.json` file containing a JSON array of OCSF Detection Finding records. See [OCSF Detection Finding format](#ocsf-detection-finding-format) for the expected structure. @@ -266,7 +265,7 @@ curl -X POST \ } ``` -### Get ingestion status +### Get Ingestion Status Monitor the progress of an ingestion job. @@ -304,7 +303,7 @@ curl -X GET \ } ``` -### List ingestion jobs +### List Ingestion Jobs Retrieve a list of ingestion jobs for the tenant. @@ -332,7 +331,7 @@ curl -X GET \ "https://api.prowler.com/api/v1/ingestions?filter[status]=completed&page[size]=10" ``` -### Get ingestion errors +### Get Ingestion Errors Retrieve error details for a specific ingestion job. @@ -346,7 +345,7 @@ curl -X GET \ https://api.prowler.com/api/v1/ingestions/3650fef9-8e5f-4808-a95f-74f0afae8499/errors ``` -## Ingestion status values +## Ingestion Status Values | Status | Description | |--------|-------------| @@ -355,7 +354,7 @@ curl -X GET \ | `completed` | All records processed successfully | | `failed` | Job encountered errors during processing | -## CI/CD integration +## CI/CD Integration Automate findings ingestion in CI/CD pipelines by setting the API key as a secret. @@ -391,7 +390,7 @@ prowler_scan: PROWLER_CLOUD_API_KEY: $PROWLER_CLOUD_API_KEY ``` -## Billing impact +## Billing Impact Each unique cloud account discovered in ingested OCSF findings counts as one **provider** in the Prowler Cloud subscription. diff --git a/docs/user-guide/tutorials/prowler-scan-scheduling.mdx b/docs/user-guide/tutorials/prowler-scan-scheduling.mdx new file mode 100644 index 0000000000..961d32ad14 --- /dev/null +++ b/docs/user-guide/tutorials/prowler-scan-scheduling.mdx @@ -0,0 +1,106 @@ +--- +title: 'Scan Scheduling' +description: 'Create, edit, and monitor recurring scans in Prowler Cloud and Enterprise.' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" +import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" + + + +Scan Scheduling lets Prowler run recurring scans for connected providers. Use it to keep findings, compliance results, and resource inventory up to date without launching every scan manually. + + + +## Prerequisites + +Before creating or editing scan schedules, ensure that: + +* At least one provider is connected. +* The user role includes the **Manage Scans** permission, configured through Role-Based Access Control (RBAC). See [RBAC Administrative Permissions](/user-guide/tutorials/prowler-app-rbac#rbac-administrative-permissions) for details. + +## Schedule Options + +A Prowler Cloud or Enterprise subscription supports the following custom recurring schedule options. Prowler self-hosted runs a daily scan automatically and does not expose custom cadence controls. + +| Schedule Option | Description | Cloud & Enterprise | Self-Hosted | +|-----------------|-------------|--------------------|-------------| +| Daily | Runs one scan every day at the selected time. | Yes | Yes | +| Every 48 hours | Runs one scan every 48 hours, anchored to the selected time. | Yes | — | +| Weekly | Runs one scan every week on the selected day and time. | Yes | — | +| Monthly | Runs one scan every month on the selected day, from day 1 to day 28. | Yes | — | + +The scan time is always selected on the hour (for example, 14:00); minutes cannot be set. The schedule time uses the browser timezone when the schedule is saved. Prowler displays the next scheduled scan in that timezone. + +## Create a Schedule From Scans + +To create a schedule from the **Scans** page: + +1. Navigate to **Scans**. +2. Click **Launch Scan**. +3. Select a connected provider. +4. Select **On a schedule**. +5. Choose the **Scan Time** and **Repeats** values. +6. Optional: select **Launch an initial scan now for immediate findings** to run a scan immediately after saving the recurring schedule. +7. Click **Save Schedule**. + + + Launch A Scan modal showing On a schedule mode, weekly schedule controls, and Save Schedule button + + +After the schedule is saved, Prowler shows a confirmation toast with a link to the **Scheduled** tab. + +## Edit Schedules From Providers + +The **Providers** page shows each provider's current schedule in the **Scan Schedule** column. Providers without a recurring schedule show **None**. + + + Providers table showing the Scan Schedule column with Daily and None schedule states + + +To edit a provider schedule: + +1. Navigate to **Providers**. +2. Open the provider row actions menu. +3. Click **Edit Scan Schedule**. +4. Update the schedule fields. +5. Click **Save**. + + + Edit Scan Schedule modal showing a weekly provider schedule and Remove Scan Schedule action + + +To stop automatic scans for a provider, click **Remove Scan Schedule** in the edit modal. Removing a schedule stops future automatic scans; existing completed scan results remain available. + +## Bulk Edit Schedules + +Use bulk schedule editing when several providers need the same recurring cadence. + +To bulk edit provider schedules: + +1. Navigate to **Providers**. +2. Select the provider rows that should receive the same schedule. +3. Open the selected-row actions menu. +4. Click **Edit Scan Schedule (N)**, where **N** is the number of selected providers. +5. Save the schedule. + +For AWS Organizations and Organizational Unit rows, **Edit Scan Schedule** applies the schedule to the connected child providers in that group. + + +Bulk schedule edits apply one schedule to every selected provider. If the wrong providers are selected, Prowler applies the same cadence to unintended providers. To recover, reopen bulk edit with the correct selection or update affected provider schedules individually. + + +## Review Scheduled Scans + +To review upcoming scheduled scans: + +1. Navigate to **Scans**. +2. Click the **Scheduled** tab. + +The **Scheduled** tab shows configured schedules, next scan time, and last scan time. Pending rows represent configured schedules that have not started their next scan yet. + + + Scans Scheduled tab showing pending scheduled scans, schedule cadence, next scan, and last scan columns + + +To edit a schedule from this tab, open the row actions menu and click **Edit Scan Schedule**. diff --git a/mcp_server/Dockerfile b/mcp_server/Dockerfile index c2759b20de..195bfe1eae 100644 --- a/mcp_server/Dockerfile +++ b/mcp_server/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ # ============================================================================= # Final stage - Minimal runtime environment # ============================================================================= -FROM python:3.13.14-alpine3.23@sha256:b0513989fa9be54569cac73f48a60320b74bb0f9ffa886568eea7e48a2432c04 +FROM python:3.13.14-alpine3.23@sha256:9fdbf2e3e82628351513560b121e2ee6ce31cac212be9e070c5a5e2769fb5e76 LABEL maintainer="https://github.com/prowler-cloud" diff --git a/permissions/prowler-additions-policy.json b/permissions/prowler-additions-policy.json index c0da045603..eb140e7c09 100644 --- a/permissions/prowler-additions-policy.json +++ b/permissions/prowler-additions-policy.json @@ -39,6 +39,8 @@ "rolesanywhere:ListTagsForResource", "rolesanywhere:ListTrustAnchors", "s3:GetAccountPublicAccessBlock", + "s3:GetObjectAcl", + "s3:ListBucket", "shield:DescribeProtection", "shield:GetSubscriptionState", "securityhub:BatchImportFindings", diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 1d40a147c7..2c0d00c499 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,15 +2,55 @@ All notable changes to the **Prowler SDK** are documented in this file. -## [5.32.0] (Prowler UNRELEASED) +## [5.32.0] (Prowler v5.32.0) ### 🚀 Added +- `exchange_application_access_policy_restricts_mailbox_apps` check for M365 provider, verifying every service principal with Microsoft Graph application-level Exchange mailbox permissions is restricted by an Exchange Online Application Access Policy, preventing tenant-wide mailbox access by unscoped applications [(#11247)](https://github.com/prowler-cloud/prowler/pull/11247) +- Per-requirement configuration validation for compliance frameworks via `ConfigRequirements`, so a requirement is reported as FAIL when its configurable checks ran with a configuration too loose to satisfy it (applied across all compliance outputs: CSV, OCSF, and console tables) [(#11669)](https://github.com/prowler-cloud/prowler/pull/11669) - `entra_conditional_access_policy_explicitly_targets_azure_devops` check for M365 provider, verifying at least one enabled Conditional Access policy explicitly includes the Azure DevOps cloud application instead of relying on a broad "All cloud apps" policy [(#11182)](https://github.com/prowler-cloud/prowler/pull/11182) +- `entra_conditional_access_policy_no_exclusion_gaps` check for M365 provider, verifying every user, group, role, or application excluded from an enabled Conditional Access policy stays in scope of another enabled policy [(#11577)](https://github.com/prowler-cloud/prowler/pull/11577) +- `entra_conditional_access_policy_groups_management_restricted` check for M365 provider, verifying every security group referenced by an enabled or report-only Conditional Access policy is management-restricted or role-assignable [(#11342)](https://github.com/prowler-cloud/prowler/pull/11342) +- `stepfunctions_statemachine_encrypted_with_cmk` check for AWS provider, verifying that each Step Functions state machine uses a customer-managed KMS key for encryption at rest rather than the default AWS-owned key [(#11538)](https://github.com/prowler-cloud/prowler/pull/11538) +- CIS Controls v8.1 universal compliance framework mapping existing checks across 18 providers (AWS, Azure, GCP, Kubernetes, M365, GitHub, AlibabaCloud, OracleCloud, GoogleWorkspace, Okta, Cloudflare, Vercel, MongoDB Atlas, OpenStack, Linode, StackIT, NHN, and Scaleway) to the 18 CIS Critical Security Controls and their Safeguards [(#11700)](https://github.com/prowler-cloud/prowler/pull/11700) +- CIS Microsoft 365 Foundations Benchmark v7.0.0 compliance framework for the M365 provider [(#11699)](https://github.com/prowler-cloud/prowler/pull/11699) +- `waf_regional_webacl_logging_enabled` check for AWS provider, verifying that each AWS WAF Classic Regional Web ACL has logging enabled to a Kinesis Data Firehose stream [(#11539)](https://github.com/prowler-cloud/prowler/pull/11539) +- `sdk_only` provider property (default `true`) and `Provider.get_app_providers()`, so a provider (built-in or external) stays CLI/SDK-only and hidden from the app unless it declares `sdk_only = False` [(#11427)](https://github.com/prowler-cloud/prowler/pull/11427) +- `Provider.get_scan_arguments()`, `Provider.get_connection_arguments()` and `Provider.get_credentials_schema()` contract methods, so a provider persisted as a stored uid plus a secret dict can be constructed and validated programmatically (to be consumed by the API in a later change) [(#11578)](https://github.com/prowler-cloud/prowler/pull/11578) +- Okta API request throttling to proactively stay under rate limits, configurable via `okta_requests_per_second` in the config file and the `--okta-requests-per-second` CLI flag, plus configurable retries via `okta_max_retries` / `--okta-retries-max-attempts` as a safety net [(#11702)](https://github.com/prowler-cloud/prowler/pull/11702) +- CIS Amazon Web Services Foundations Benchmark v7.0.0 compliance framework for the AWS provider, adding the new Organizations section (2.1.1-2.1.6), resource policy (2.21), web front-end access logging (4.10), and VPC Endpoints (6.8) recommendations [(#11707)](https://github.com/prowler-cloud/prowler/pull/11707) +- CIS Microsoft Azure Foundations Benchmark v6.0.0 compliance framework for the Azure provider [(#11708)](https://github.com/prowler-cloud/prowler/pull/11708) +- CIS Google Cloud Platform Foundation Benchmark v5.0.0 compliance framework for the GCP provider [(#11714)](https://github.com/prowler-cloud/prowler/pull/11714) +- CIS Kubernetes Benchmark v2.0.1 compliance framework for the Kubernetes provider [(#11722)](https://github.com/prowler-cloud/prowler/pull/11722) +- CIS GitHub Benchmark v1.2.0 compliance framework for the GitHub provider [(#11719)](https://github.com/prowler-cloud/prowler/pull/11719) +- AWS Bedrock AgentCore privilege escalation paths in the IAM privilege escalation checks, covering Runtime, Harness, Code Interpreter and Custom Browser [(#11726)](https://github.com/prowler-cloud/prowler/pull/11726) +- `--scan-secrets-validate` flag and `aws.secrets_validate` configuration option to optionally validate the secrets discovered by the secret-scanning checks against the provider APIs; secrets confirmed to be live are reported as critical [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) +- `apigateway_restapi_no_secrets_in_stage_variables` check for AWS provider, scanning API Gateway REST API stage variables for hardcoded secrets such as passwords, API keys, and tokens [(#11188)](https://github.com/prowler-cloud/prowler/pull/11188) +- `s3_bucket_object_public` check for AWS provider, spot-checking a configurable sample of object ACLs in each bucket and flagging objects granted to the AllUsers or AuthenticatedUsers groups; disabled by default and opted into via the `s3_bucket_object_public_enabled` configuration option [(#9517)](https://github.com/prowler-cloud/prowler/pull/9517) +- Azure provider now supports `--azure-resource-group` to scope resource-level checks to specific resource groups across all accessible subscriptions [(#10657)](https://github.com/prowler-cloud/prowler/pull/10657) + +### 🔄 Changed + +- Replaced the `detect-secrets` library with [Kingfisher](https://github.com/mongodb/kingfisher) as the engine for the secret-scanning checks; scans run fully offline by default and obvious placeholder values are no longer reported as findings [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) +- Removed the `detect_secrets_plugins` configuration option, which is no longer used by the new secret-scanning engine [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) +- `awslambda_function_no_secrets_in_code` now supports a `secrets_ignore_files` audit-config option to skip files inside the deployment package by glob pattern (e.g. `*.deps.json`), suppressing .NET dependency-manifest false positives without masking real secrets [(#11222)](https://github.com/prowler-cloud/prowler/pull/11222) +- AWS scans for EBS snapshots, Backup recovery points, CloudWatch log groups, Lambda functions, ECS task definitions, and CodeArtifact packages now support configurable resource analysis limits via `aws.max_scanned_resources_per_service`; limits are disabled by default and only positive values cap analyzed resources [(#11228)](https://github.com/prowler-cloud/prowler/pull/11228) + +### 🐞 Fixed + +- GitHub `repository_has_codeowners_file` check no longer flags archived repositories, since they are read-only and cannot be updated without first being unarchived, making the finding not actionable [(#11735)](https://github.com/prowler-cloud/prowler/pull/11735) +- Report secret-scanning checks as `MANUAL` instead of `PASS` when the scanner fails (non-zero exit, timeout, unparseable output or missing binary), so a scanner failure is no longer indistinguishable from "no secrets found" [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) +- Avoid a false `FAIL` in `cloudwatch_log_group_no_secrets_in_logs` when a multiline event's secrets are all removed by `secrets_ignore_patterns` during the rescan [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) +- Key the `cloudwatch_log_group_no_secrets_in_logs` secret scan by log group ARN instead of name, so same-named log groups and streams in different regions no longer collide and reuse each other's findings [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) +- Compliance frameworks contributed by several external packages under the same provider are now merged instead of overwritten, so every entry-point directory a provider contributes is discovered [(#11578)](https://github.com/prowler-cloud/prowler/pull/11578) +- Azure PostgreSQL flexible server collection no longer drops the remaining servers in a subscription when one server fails to collect; the `connection_throttle.enable` parameter (removed in PostgreSQL 16+) is treated as absent only when the Azure SDK reports it as not found, so unexpected lookup failures are not silently reported as throttling disabled [(#11595)](https://github.com/prowler-cloud/prowler/pull/11595) +- Azure `keyvault_logging_enabled` now accepts Key Vault diagnostic settings that enable the explicit `AuditEvent` category, avoiding false failures when Azure returns category-based logs without category groups [(#11660)](https://github.com/prowler-cloud/prowler/pull/11660) +- GitHub default branch protection checks now evaluate repository rulesets in addition to classic branch protection, avoiding false positives for repositories that enforce protection through rulesets [(#11723)](https://github.com/prowler-cloud/prowler/pull/11723) +- Okta, Alibaba Cloud and OpenStack scan-config sections are now validated against a registered schema instead of being silently accepted, so their configurable thresholds (session/idle timeouts, retention days, image-sharing and secret-scanning settings) log a warning and fall back to the built-in default whenever a value is out of range [(#11725)](https://github.com/prowler-cloud/prowler/pull/11725) --- -## [5.31.1] (Prowler UNRELEASED) +## [5.31.1] (Prowler v5.31.1) ### 🐞 Fixed @@ -283,7 +323,6 @@ All notable changes to the **Prowler SDK** are documented in this file. - `bedrock_prompt_management_exists` check for AWS provider [(#10878)](https://github.com/prowler-cloud/prowler/pull/10878) - 8 Gmail attachment safety and spoofing protection checks for Google Workspace provider using the Cloud Identity Policy API [(#10980)](https://github.com/prowler-cloud/prowler/pull/10980) - `bedrock_prompt_encrypted_with_cmk` check for AWS provider [(#10905)](https://github.com/prowler-cloud/prowler/pull/10905) - ### 🔄 Changed - Azure Network Watcher flow log checks now require workspace-backed Traffic Analytics for `network_flow_log_captured_sent` and align metadata with VNet-compatible flow log guidance [(#10645)](https://github.com/prowler-cloud/prowler/pull/10645) diff --git a/prowler/compliance/alibabacloud/cis_2.0_alibabacloud.json b/prowler/compliance/alibabacloud/cis_2.0_alibabacloud.json index 7cda08efbb..9a05c1997f 100644 --- a/prowler/compliance/alibabacloud/cis_2.0_alibabacloud.json +++ b/prowler/compliance/alibabacloud/cis_2.0_alibabacloud.json @@ -109,6 +109,14 @@ ], "Checks": [ "ram_user_console_access_unused" + ], + "ConfigRequirements": [ + { + "Check": "ram_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90 + } ] }, { @@ -841,6 +849,14 @@ ], "Checks": [ "sls_logstore_retention_period" + ], + "ConfigRequirements": [ + { + "Check": "sls_logstore_retention_period", + "ConfigKey": "min_log_retention_days", + "Operator": "gte", + "Value": 365 + } ] }, { @@ -1353,6 +1369,14 @@ ], "Checks": [ "rds_instance_sql_audit_retention" + ], + "ConfigRequirements": [ + { + "Check": "rds_instance_sql_audit_retention", + "ConfigKey": "min_rds_audit_retention_days", + "Operator": "gte", + "Value": 180 + } ] }, { @@ -1551,6 +1575,14 @@ ], "Checks": [ "cs_kubernetes_cluster_check_recent" + ], + "ConfigRequirements": [ + { + "Check": "cs_kubernetes_cluster_check_recent", + "ConfigKey": "max_cluster_check_days", + "Operator": "lte", + "Value": 7 + } ] }, { diff --git a/prowler/compliance/alibabacloud/prowler_threatscore_alibabacloud.json b/prowler/compliance/alibabacloud/prowler_threatscore_alibabacloud.json index ca7a030dc2..0bfd47df7e 100644 --- a/prowler/compliance/alibabacloud/prowler_threatscore_alibabacloud.json +++ b/prowler/compliance/alibabacloud/prowler_threatscore_alibabacloud.json @@ -47,6 +47,14 @@ "Checks": [ "ram_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "ram_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90 + } + ], "Attributes": [ { "Title": "Inactive users disabled for console access", @@ -399,6 +407,14 @@ "LevelOfRisk": 3, "Weight": 10 } + ], + "ConfigRequirements": [ + { + "Check": "cs_kubernetes_cluster_check_weekly", + "ConfigKey": "max_cluster_check_days", + "Operator": "lte", + "Value": 7 + } ] }, { @@ -695,6 +711,14 @@ "Checks": [ "rds_instance_sql_audit_retention" ], + "ConfigRequirements": [ + { + "Check": "rds_instance_sql_audit_retention", + "ConfigKey": "min_rds_audit_retention_days", + "Operator": "gte", + "Value": 180 + } + ], "Attributes": [ { "Title": "RDS SQL audit retention configured", diff --git a/prowler/compliance/aws/asd_essential_eight_aws.json b/prowler/compliance/aws/asd_essential_eight_aws.json index 00b39817e3..dd39c44268 100644 --- a/prowler/compliance/aws/asd_essential_eight_aws.json +++ b/prowler/compliance/aws/asd_essential_eight_aws.json @@ -13,6 +13,14 @@ "config_recorder_all_regions_enabled", "inspector2_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "1 Patch applications", @@ -260,6 +268,14 @@ "config_recorder_all_regions_enabled", "inspector2_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "2 Patch operating systems", @@ -742,6 +758,14 @@ "accessanalyzer_enabled", "accessanalyzer_enabled_without_findings" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "4 Restrict administrative privileges", diff --git a/prowler/compliance/aws/aws_account_security_onboarding_aws.json b/prowler/compliance/aws/aws_account_security_onboarding_aws.json index 1d038537f0..1910bac223 100644 --- a/prowler/compliance/aws/aws_account_security_onboarding_aws.json +++ b/prowler/compliance/aws/aws_account_security_onboarding_aws.json @@ -37,6 +37,26 @@ "guardduty_is_enabled", "accessanalyzer_enabled", "macie_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -259,6 +279,20 @@ "Checks": [ "guardduty_is_enabled", "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -514,6 +548,14 @@ "Checks": [ "accessanalyzer_enabled", "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -530,6 +572,20 @@ "securityhub_enabled", "accessanalyzer_enabled", "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -666,6 +722,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -680,6 +744,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -694,6 +766,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -708,6 +788,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -722,6 +810,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -736,6 +832,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -762,6 +866,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -777,6 +889,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_centrally_managed" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -792,6 +912,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -807,6 +935,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -822,6 +958,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -837,6 +981,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -852,6 +1004,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -867,6 +1027,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -882,6 +1050,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -897,6 +1073,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -912,6 +1096,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/aws_ai_security_framework_aws.json b/prowler/compliance/aws/aws_ai_security_framework_aws.json index c6a0a8504b..9b87f7464d 100644 --- a/prowler/compliance/aws/aws_ai_security_framework_aws.json +++ b/prowler/compliance/aws/aws_ai_security_framework_aws.json @@ -404,6 +404,14 @@ "Checks": [ "accessanalyzer_enabled", "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -860,6 +868,20 @@ "guardduty_lambda_protection_enabled", "guardduty_rds_protection_enabled", "guardduty_ec2_malware_protection_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_delegated_admin_enabled_all_regions", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -894,6 +916,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -964,6 +994,14 @@ "Checks": [ "config_recorder_all_regions_enabled", "config_recorder_using_aws_service_role" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1157,4 +1195,4 @@ "Checks": [] } ] -} \ No newline at end of file +} 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 cea7ad1655..b58a74bcaa 100644 --- a/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json +++ b/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json @@ -20,6 +20,14 @@ "SectionDescription": "This section contains recommendations for configuring ACM resources.", "Service": "ACM" } + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_expiration_check", + "ConfigKey": "days_to_expire_threshold", + "Operator": "gte", + "Value": 30 + } ] }, { @@ -29,6 +37,17 @@ "Checks": [ "acm_certificates_with_secure_key_algorithms" ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } + ], "Attributes": [ { "ItemId": "ACM.2", @@ -777,6 +796,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "Config.1", @@ -892,6 +919,14 @@ "Checks": [ "documentdb_cluster_backup_enabled" ], + "ConfigRequirements": [ + { + "Check": "documentdb_cluster_backup_enabled", + "ConfigKey": "minimum_backup_retention_period", + "Operator": "gte", + "Value": 7 + } + ], "Attributes": [ { "ItemId": "DocumentDB.2", @@ -1959,6 +1994,14 @@ "SectionDescription": "This section contains recommendations for configuring ELB resources.", "Service": "ELB" } + ], + "ConfigRequirements": [ + { + "Check": "elb_is_in_multiple_az", + "ConfigKey": "elb_min_azs", + "Operator": "gte", + "Value": 2 + } ] }, { @@ -1993,6 +2036,14 @@ "SectionDescription": "This section contains recommendations for configuring ELB resources.", "Service": "ELB" } + ], + "ConfigRequirements": [ + { + "Check": "elbv2_is_in_multiple_az", + "ConfigKey": "elbv2_min_azs", + "Operator": "gte", + "Value": 2 + } ] }, { @@ -2370,6 +2421,14 @@ "Checks": [ "guardduty_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "GuardDuty.1", @@ -2547,6 +2606,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90 + } + ], "Attributes": [ { "ItemId": "IAM.8", @@ -2635,6 +2708,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], "Attributes": [ { "ItemId": "IAM.22", @@ -2791,6 +2878,40 @@ "SectionDescription": "This section contains recommendations for configuring Lambda resources.", "Service": "Lambda" } + ], + "ConfigRequirements": [ + { + "Check": "awslambda_function_using_supported_runtimes", + "ConfigKey": "obsolete_lambda_runtimes", + "Operator": "superset", + "Value": [ + "java8", + "go1.x", + "provided", + "python3.6", + "python2.7", + "python3.7", + "python3.8", + "nodejs4.3", + "nodejs4.3-edge", + "nodejs6.10", + "nodejs", + "nodejs8.10", + "nodejs10.x", + "nodejs12.x", + "nodejs14.x", + "nodejs16.x", + "dotnet5.0", + "dotnet6", + "dotnet7", + "dotnetcore1.0", + "dotnetcore2.0", + "dotnetcore2.1", + "dotnetcore3.1", + "ruby2.5", + "ruby2.7" + ] + } ] }, { @@ -2951,6 +3072,14 @@ "Checks": [ "neptune_cluster_backup_enabled" ], + "ConfigRequirements": [ + { + "Check": "neptune_cluster_backup_enabled", + "ConfigKey": "minimum_backup_retention_period", + "Operator": "gte", + "Value": 7 + } + ], "Attributes": [ { "ItemId": "Neptune.5", diff --git a/prowler/compliance/aws/aws_foundational_technical_review_aws.json b/prowler/compliance/aws/aws_foundational_technical_review_aws.json index 691a8ba7f1..9d8e3cfdc8 100644 --- a/prowler/compliance/aws/aws_foundational_technical_review_aws.json +++ b/prowler/compliance/aws/aws_foundational_technical_review_aws.json @@ -176,6 +176,14 @@ "iam_user_with_temporary_credentials", "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json b/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json index 4c4eaee252..a025bb3a3c 100644 --- a/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json +++ b/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json @@ -585,6 +585,14 @@ "cloudtrail_multi_region_enabled", "vpc_flow_logs_enabled", "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -646,6 +654,20 @@ "guardduty_no_high_severity_findings", "macie_is_enabled", "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -778,6 +800,14 @@ "guardduty_is_enabled", "vpc_flow_logs_enabled", "apigateway_restapi_authorizers_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/c5_aws.json b/prowler/compliance/aws/c5_aws.json index 8847469154..269a5ce308 100644 --- a/prowler/compliance/aws/c5_aws.json +++ b/prowler/compliance/aws/c5_aws.json @@ -382,6 +382,14 @@ "cloudtrail_multi_region_enabled", "config_recorder_all_regions_enabled", "s3_multi_region_access_point_public_access_block" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2234,6 +2242,14 @@ "vpc_different_regions", "autoscaling_group_multiple_az", "storagegateway_gateway_fault_tolerant" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2261,6 +2277,14 @@ "organizations_scp_check_deny_regions", "s3_multi_region_access_point_public_access_block", "vpc_different_regions" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2308,6 +2332,14 @@ "organizations_scp_check_deny_regions", "s3_multi_region_access_point_public_access_block", "vpc_different_regions" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2978,6 +3010,14 @@ "guardduty_is_enabled", "athena_workgroup_enforce_configuration", "shield_advanced_protection_in_global_accelerators" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -3481,6 +3521,14 @@ "cloudtrail_cloudwatch_logging_enabled", "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4299,6 +4347,14 @@ "guardduty_no_high_severity_findings", "guardduty_rds_protection_enabled", "guardduty_s3_protection_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4920,6 +4976,17 @@ "elbv2_nlb_tls_termination_enabled", "transfer_server_in_transit_encryption_enabled", "kafka_cluster_mutual_tls_authentication_enabled" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -4946,6 +5013,17 @@ "elbv2_nlb_tls_termination_enabled", "transfer_server_in_transit_encryption_enabled", "kafka_cluster_mutual_tls_authentication_enabled" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -5220,6 +5298,14 @@ "rds_instance_default_admin", "accessanalyzer_enabled", "efs_access_point_enforce_user_identity" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5737,6 +5823,14 @@ "Checks": [ "accessanalyzer_enabled", "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6100,6 +6194,17 @@ "cloudfront_distributions_origin_traffic_encrypted", "glue_development_endpoints_job_bookmark_encryption_enabled", "cloudtrail_kms_encryption_enabled" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -6196,6 +6301,17 @@ "elb_ssl_listeners_use_acm_certificate", "iam_no_expired_server_certificates_stored", "rds_instance_certificate_expiration" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -6307,6 +6423,17 @@ "elb_ssl_listeners_use_acm_certificate", "iam_no_expired_server_certificates_stored", "rds_instance_certificate_expiration" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -6393,6 +6520,14 @@ "sns_topics_not_publicly_accessible", "sqs_queues_not_publicly_accessible", "vpc_peering_routing_tables_with_least_privilege" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6412,6 +6547,14 @@ "ec2_instance_profile_attached", "accessanalyzer_enabled", "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6587,6 +6730,17 @@ "kms_cmk_not_multi_region", "kms_key_not_publicly_accessible", "ec2_ebs_volume_encryption" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -6809,6 +6963,17 @@ "secretsmanager_not_publicly_accessible", "secretsmanager_secret_rotated_periodically", "secretsmanager_secret_unused" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -6842,6 +7007,17 @@ ], "Checks": [ "acm_certificates_with_secure_key_algorithms" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -6915,6 +7091,17 @@ "secretsmanager_secret_rotated_periodically", "secretsmanager_secret_unused", "acm_certificates_with_secure_key_algorithms" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -6937,6 +7124,17 @@ "secretsmanager_secret_rotated_periodically", "secretsmanager_secret_unused", "acm_certificates_with_secure_key_algorithms" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -8042,6 +8240,14 @@ "cloudtrail_multi_region_enabled", "cloudtrail_multi_region_enabled_logging_management_events", "cloudtrail_log_file_validation_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -8810,6 +9016,14 @@ "guardduty_is_enabled", "cloudtrail_log_file_validation_enabled", "ssmincidents_enabled_with_plans" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -9732,6 +9946,14 @@ "accessanalyzer_enabled_without_findings", "cloudfront_distributions_s3_origin_access_control", "cloudtrail_logs_s3_bucket_access_logging_enabled" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -10367,6 +10589,14 @@ "Checks": [ "accessanalyzer_enabled", "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -10457,6 +10687,14 @@ "ec2_instance_profile_attached", "iam_role_cross_account_readonlyaccess_policy", "iam_securityaudit_role_created" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/ccc_aws.json b/prowler/compliance/aws/ccc_aws.json index 003e9e17a0..7935424193 100644 --- a/prowler/compliance/aws/ccc_aws.json +++ b/prowler/compliance/aws/ccc_aws.json @@ -275,6 +275,17 @@ "acm_certificates_expiration_check", "acm_certificates_with_secure_key_algorithms", "acm_certificates_transparency_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -794,6 +805,17 @@ ], "Checks": [ "acm_certificates_with_secure_key_algorithms" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -1504,6 +1526,14 @@ "iam_policy_no_full_access_to_kms", "iam_policy_no_full_access_to_cloudtrail", "iam_policy_attached_only_to_group_or_roles" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1666,6 +1696,14 @@ "cloudwatch_changes_to_network_route_tables_alarm_configured", "cloudwatch_changes_to_vpcs_alarm_configured", "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1791,6 +1829,14 @@ "cloudtrail_threat_detection_enumeration", "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4311,6 +4357,14 @@ ], "Checks": [ "acm_certificates_expiration_check" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_expiration_check", + "ConfigKey": "days_to_expire_threshold", + "Operator": "gte", + "Value": 30 + } ] }, { @@ -6176,6 +6230,20 @@ "Checks": [ "iam_user_accesskey_unused", "iam_user_console_access_unused" + ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90 + } ] }, { @@ -6272,6 +6340,14 @@ "cloudwatch_log_metric_filter_root_usage", "cloudwatch_log_metric_filter_sign_in_without_mfa", "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6374,6 +6450,14 @@ "Checks": [ "accessanalyzer_enabled", "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/cis_1.4_aws.json b/prowler/compliance/aws/cis_1.4_aws.json index 3efc29fd5b..b373a04665 100644 --- a/prowler/compliance/aws/cis_1.4_aws.json +++ b/prowler/compliance/aws/cis_1.4_aws.json @@ -75,6 +75,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -265,6 +279,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -736,6 +758,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "3 Logging", diff --git a/prowler/compliance/aws/cis_1.5_aws.json b/prowler/compliance/aws/cis_1.5_aws.json index f7307bb7f4..6a60d786a2 100644 --- a/prowler/compliance/aws/cis_1.5_aws.json +++ b/prowler/compliance/aws/cis_1.5_aws.json @@ -75,6 +75,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -265,6 +279,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -802,6 +824,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "3 Logging", @@ -1054,6 +1084,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "4 Monitoring", diff --git a/prowler/compliance/aws/cis_2.0_aws.json b/prowler/compliance/aws/cis_2.0_aws.json index 4f8c4b2c23..e255bd43e1 100644 --- a/prowler/compliance/aws/cis_2.0_aws.json +++ b/prowler/compliance/aws/cis_2.0_aws.json @@ -75,6 +75,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -265,6 +279,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -802,6 +824,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "3 Logging", @@ -1054,6 +1084,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "4 Monitoring", diff --git a/prowler/compliance/aws/cis_3.0_aws.json b/prowler/compliance/aws/cis_3.0_aws.json index dd4c75a2f7..5540bc40cf 100644 --- a/prowler/compliance/aws/cis_3.0_aws.json +++ b/prowler/compliance/aws/cis_3.0_aws.json @@ -75,6 +75,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -265,6 +279,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -756,6 +778,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "3 Logging", @@ -1008,6 +1038,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "4 Monitoring", diff --git a/prowler/compliance/aws/cis_4.0_aws.json b/prowler/compliance/aws/cis_4.0_aws.json index 0c40f8ac09..c8787ef409 100644 --- a/prowler/compliance/aws/cis_4.0_aws.json +++ b/prowler/compliance/aws/cis_4.0_aws.json @@ -254,6 +254,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -431,6 +445,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -750,6 +772,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "3 Logging", @@ -1234,6 +1264,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "4 Monitoring", diff --git a/prowler/compliance/aws/cis_5.0_aws.json b/prowler/compliance/aws/cis_5.0_aws.json index e870878c6b..0c8d46e170 100644 --- a/prowler/compliance/aws/cis_5.0_aws.json +++ b/prowler/compliance/aws/cis_5.0_aws.json @@ -232,6 +232,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -409,6 +423,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -728,6 +750,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "3 Logging", @@ -1212,6 +1242,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "4 Monitoring", diff --git a/prowler/compliance/aws/cis_6.0_aws.json b/prowler/compliance/aws/cis_6.0_aws.json index 643c192b7b..7ad62a4b65 100644 --- a/prowler/compliance/aws/cis_6.0_aws.json +++ b/prowler/compliance/aws/cis_6.0_aws.json @@ -232,6 +232,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], "Attributes": [ { "Section": "2 Identity and Access Management", @@ -409,6 +423,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "2 Identity and Access Management", @@ -728,6 +750,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "4 Logging", @@ -1212,6 +1242,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "5 Monitoring", diff --git a/prowler/compliance/aws/cis_7.0_aws.json b/prowler/compliance/aws/cis_7.0_aws.json new file mode 100644 index 0000000000..f4f7fedff8 --- /dev/null +++ b/prowler/compliance/aws/cis_7.0_aws.json @@ -0,0 +1,1610 @@ +{ + "Framework": "CIS", + "Name": "CIS Amazon Web Services Foundations Benchmark v7.0.0", + "Version": "7.0", + "Provider": "AWS", + "Description": "The CIS Amazon Web Services Foundations Benchmark provides prescriptive guidance for configuring security options for a subset of Amazon Web Services with an emphasis on foundational, testable, and architecture agnostic settings.", + "Requirements": [ + { + "Id": "2.1.1", + "Description": "Ensure centralized root access in AWS Organizations", + "Checks": [ + "iam_root_credentials_management_enabled" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure centralized root access management is enabled to manage and secure root user credentials for member accounts in AWS Organizations. This allows the management account and an optional delegated administrator account to centrally delete, prevent recovery of, and if necessary, perform short-lived, scoped root-required actions in member accounts without maintaining long-term root user credentials in each account.", + "RationaleStatement": "The AWS account root user is a powerful, default administrative identity that is difficult to manage safely across many accounts. When each member account manages its own root credentials, organizations often end up with numerous long-lived root passwords, access keys, and MFA devices that are hard to inventory, rotate, and protect. Centralized root access management lets security teams remove or avoid creating root user credentials in member accounts, centrally review and manage any remaining root credentials, and perform necessary root-only tasks via short-term, task-scoped root sessions. This significantly reduces privileged credential sprawl, supports least privilege and dedicated administrator models, and improves visibility and auditability of root-level activity across the organization.", + "ImpactStatement": "Enabling centralized root access management changes how root user access is obtained and used in member accounts, but it does not automatically remove existing root credentials. Organizations must plan when and how to delete or disable any existing root passwords, access keys, signing certificates, and MFA devices in member accounts and update any workflows that still rely on direct root sign-in. Security and operations teams will need to use centrally initiated, short-lived root sessions for exceptional tasks that truly require root. This may require procedural changes and additional training, but it significantly reduces long-lived privileged credential sprawl across the organization.", + "RemediationProcedure": "1. Sign in to the AWS Management Console with the management account. 2. In the console search bar, type Organizations and open AWS Organizations. - On the Overview page, confirm that an Organization exists and that this account is listed as the Management account. 3. In AWS Organizations, choose Services. Locate AWS Identity and Access Management in the list and, if it is not already enabled, choose Enable trusted access and confirm. - This allows IAM to integrate with AWS Organizations to manage root access centrally. 4. In the console search bar, type IAM and open IAM. In the left navigation pane, choose Root access management. If you see Root access management is disabled, choose Enable. - In the enable dialog, confirm that you want to - \"Root credentials management\" and if desired - \"Privileged root actions in member accounts\" - In the Delegated administrator field, enter the account ID of the account that will manage root user access and take privileged actions on member accounts. AWS recommends using an account intended for security or management purposes, not a general workload account. - When you enable centralized root access in the console, IAM also enables trusted access for IAM in AWS Organizations if it isn't already enabled. - Choose Enable to save the configuration.", + "AuditProcedure": "1. Sign in to the AWS Management Console with the management account. 2. In the console search bar, type Organizations and open AWS Organizations. - On the Overview page, confirm that an Organization exists and that this account is listed as the Management account. 3. In AWS Organizations, choose Services. - Confirm that AWS Identity and Access Management appears in the list of services with trusted access enabled. 4. In the console search bar, type IAM and open IAM. In the left navigation pane, choose Root access management. Check the status banner. - If you see that Root access management is enabled and the feature card shows that root credentials management is turned on for member accounts, the organization has centralized root access management enabled. - If you see Root access management is disabled with an option to Enable, centralized root access is not yet enabled. 5. (Optional) On the same Root access management page, review the Delegated administrator information (if shown). - Confirm that the delegated account (if present) is a security or management-focused account, not a general workload account.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.2", + "Description": "Ensure authorization guardrails for all AWS Organization accounts", + "Checks": [], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that one or more baseline authorization policies such as Service Control Policies (SCPs) and/or Resource Control Policies (RCPs) are attached to all member accounts in AWS Organizations in accordance with organizational security requirements. Authorization policies act as preventive permission guardrails: SCPs define the maximum available permissions for IAM principals within accounts, while RCPs define the maximum available permissions for resources within accounts. These policies can enforce security invariants such as preventing disabling of key security services, restricting use of unapproved AWS Regions, or blocking external access to sensitive resources.", + "RationaleStatement": "Authorization policies do not grant permissions but instead set organization-wide limits on what actions principals can perform (SCPs) and what access can be granted to resources (RCPs), regardless of local IAM or resource-based policies. Without baseline guardrail authorization policies, each account can grant excessive or inconsistent permissions that disable logging, weaken security services, allow use of unapproved Regions and services, or permit unintended external access to resources. Attaching standard authorization policies to all member accounts enforces preventive, centralized control over high-risk actions and access patterns, supports least-privilege and role-based access control at scale, and helps ensure that all accounts and resources operate within the organization's defined security baseline.", + "ImpactStatement": "Enforcing baseline authorization policies for all member accounts can initially block some existing patterns, such as use of unapproved Regions, disabling security services, or granting broader permissions than the guardrails allow. Teams may need to adjust IAM policies, deployment pipelines, and exception processes so legitimate use cases remain possible within the new guardrails. This can introduce short-term operational overhead and require careful testing, especially when attaching new policies at the root or OU level.", + "RemediationProcedure": "Design or confirm baseline guardrail SCPs. 1. From the AWS Organizations console, go to Policies → Service control policies. - If you already have standard guardrail SCPs that implement your security baseline, note their names. - If you do not have such policies, choose Create policy and create at least one baseline guardrail SCP that encodes non-negotiable security requirements. 2. Do the same step as above but for RCPs if needed. From the AWS Organizations console, go to Policies → Resource control policies. 3. Attach guardrail authorization policies to the root and/or OUs. In AWS Organizations, choose AWS accounts, then select the Root of the organization. - Go to the Policies tab, then within section for Service control policies, choose Attach, and select the baseline guardrail SCP(s) you identified or created in step 1. - If using RCPs, then within section for Resource control policies, choose Attach, and select the baseline guardrail RCP(s) you identified or created in step 2. - If your design uses different guardrails per OU (for example, stricter policies for production OU), select each OU in turn and attach the appropriate guardrail SCPs and RCPs to those OUs. - AWS recommends testing authorization policies in a staging OU before attaching them broadly to the root to avoid unintended service disruption.", + "AuditProcedure": "Pre-requisite: you must run these CLI commands in the management account for the AWS Organization. 1. Before auditing, document or confirm your organization's baseline guardrail requirements. Common examples include: - Prevent disabling CloudTrail, AWS Config, GuardDuty, or Security Hub - Restrict usage to approved AWS Regions only - Protect central security or logging roles from modification - Deny external principal access to sensitive resources 2. List all SCPs and RCPs in the organization: ``` aws organizations list-policies --filter SERVICE_CONTROL_POLICY aws organizations list-policies --filter RESOURCE_CONTROL_POLICY ``` This returns a list of SCP/RCP policy IDs and names. 3. For each SCP/RCP, retrieve and review the policy document to determine if it implements your baseline guardrail requirements: ``` aws organizations describe-policy --policy-id ``` Review the `Content` field in the output to confirm the policy enforces organizational security requirements. If no SCPs/RCPs exist that implement your documented baseline guardrail requirements, note this as a gap and proceed to remediation. 4. List all accounts in the organization and note the account IDs: ``` aws organizations list-accounts --query 'Accounts[?Status==`ACTIVE`].[Id,Name]' --output table ``` 5. For each baseline guardrail Authorization policy identified in Step 2, list the accounts and OUs to which it is attached: ``` aws organizations list-targets-for-policy --policy-id ``` The output shows `TargetId` values representing accounts, OUs, or the root. 6. Compare the list of attached targets to your full account list. - If the policy is attached to the root, all accounts in the organization inherit it and this policy passes for coverage. - If the policy is attached to specific OUs, verify that all active member accounts belong to those OUs. - If the policy is attached to individual accounts, verify that all active member accounts are included. The environment passes this recommendation if: - All baseline guardrail authorization policies required by organizational security requirements exist. - Each baseline guardrail authorization policy is attached to all active member accounts either directly or via OU/root inheritance.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.3", + "Description": "Ensure Organizations management account is not used for workloads", + "Checks": [], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that the AWS Organizations management account is used only for organizational governance tasks and does not host production workloads, applications, or business data. The management account is the most privileged account in an AWS Organization and performs sensitive administrative functions such as creating and managing member accounts, applying service control policies (SCPs), and managing consolidated billing. Workloads, applications, and associated data should be deployed in dedicated member accounts, not in the management account.", + "RationaleStatement": "The management account has unique privileges that cannot be restricted by SCPs, making it the highest-risk account in an organization. Deploying workloads or storing business data in the management account increases the attack surface and blast radius of a compromise. If a workload vulnerability or misconfiguration occurs in the management account, it could grant attackers access to organization-wide administrative capabilities.", + "ImpactStatement": "Restricting the management account to governance-only use may require creating new member accounts, redesigning existing account boundaries, and migrating workloads and data out of the management account. This can introduce short-term complexity and operational overhead. However, it reduces the blast radius of a compromise, simplifies security controls in the most privileged account, and aligns the environment with AWS multi-account and workload-isolation best practices.", + "RemediationProcedure": "1. Inventory all workload resources currently in the management account (compute, storage, databases, application services). 2. For each class of workload resource (for example, production, non-production, shared services), create or confirm dedicated member accounts within the organization and place them into the appropriate OUs. 3. For each workload resource, design a migration plan to the appropriate member account. - Execute the migrations in phases, starting with lower-risk environments (for example, development/test) before production. 4. Review and adjust IAM roles and permissions in the management account so that only personnel responsible for organization governance and security have access 5. Update architecture diagrams, runbooks, and onboarding processes to state that new workloads must be deployed only into designated workload accounts, not the management account.", + "AuditProcedure": "1. Confirm which AWS account is the management account for the organization (for example, via AWS Organizations \"Overview\" page or organizational documentation). 2. Ensure you have read-only access to review resources in this account. 3. Use your organization's standard discovery methods (for example, AWS Config, CMDB/asset inventory, or CSPM) to obtain a list of services and resources running in the management account. - At a minimum, identify compute, storage, database, and application services (for example, EC2, Lambda, ECS, S3, RDS, DynamoDB, API Gateway, load balancers). 4. For each identified resource, determine whether it is: - Governance/security: resources that support centralized management, logging, audit, or security (for example, org-wide CloudTrail, Config aggregator, Security Hub or GuardDuty delegated admin, billing/cost tooling). - Workload/business: resources that support business applications, production or non-production workloads, or customer-facing systems. 5. If any workload/business resources are present in the management account, record this as a gap and document the affected services and resource types", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.4", + "Description": "Ensure Organizational Units are structured by environment and sensitivity", + "Checks": [], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that AWS Organizations Organizational Units (OUs) are structured primarily by environment (for example, production, non-production, sandbox) and sensitivity (for example, security, logging, shared services, regulated workloads), rather than mirroring the corporate org chart. OUs should group accounts that share similar security requirements and controls so that appropriate authorization policies and other guardrails can be applied consistently at the OU level.", + "RationaleStatement": "A clear OU structure based on environment and sensitivity makes it easier to apply consistent guardrails and centralized security controls to accounts that have similar risk profiles and compliance needs. Poorly defined or ad-hoc OU structures complicate policy management, increase the chance of misapplied controls, and can lead to mixing workloads with different data sensitivities under the same set of controls.", + "ImpactStatement": "Restructuring OUs by environment and sensitivity can require moving accounts, changing inherited policies, and updating automation that assumes existing OU paths. This may introduce short-term operational overhead, including policy revalidation, testing of workloads under new guardrails, and coordination with application and platform teams to avoid unintended service disruption.", + "RemediationProcedure": "1. Work with security, platform, and application teams to agree on a small set of top-level OUs such as: - Security / Management - Shared Services / Infrastructure - Prod - Non-Prod (dev, test, staging) - You may also define dedicated OUs for highly regulated workloads. 2. In the AWS Organizations console (management account), navigate to AWS Accounts. Under the root, create the agreed top-level OUs. If needed, create child OUs under these. 3. Export or list all existing accounts and their current OUs. Create a simple mapping from each account to its target OU based on environment and sensitivity. 4. In the AWS Organizations console (management account), navigate to AWS Accounts. Move accounts into the new environment/sensitivity-based OUs according to your mapping. - Start with low-risk accounts (for example, sandbox and non-production) to validate effects of inherited policies and guardrails before moving production and high-sensitivity accounts. 5. After accounts have been moved, remove old OUs that no longer reflect the target structure. - Ensure no active accounts remain directly under the root unless explicitly justified and documented. 6. Update architecture docs, onboarding runbooks, and account request processes to require new accounts to be created in the correct OU based on environment and sensitivity.", + "AuditProcedure": "1. From the management account, use AWS Organizations console to obtain: - The full OU hierarchy (root, top-level and child OUs). - The list of accounts in each OU. 2. Review top-level and key OUs and determine whether they are clearly aligned to: - Environment (for example, production, non-production, sandbox). - Sensitivity/function (for example, security, logging, shared services, regulated). - Note any OUs whose purpose is unclear or that appear to be organized mainly by department or owner rather than environment/sensitivity. 3. For each environment/sensitivity OU, select a sample of accounts and verify that their primary workloads match the OU's stated purpose. - Note any accounts that mix production and non-production workloads in the same OU when separate OUs are defined. - Note any accounts that place highly sensitive or regulated workloads in OUs that are intended for lower-sensitivity use.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.5", + "Description": "Ensure delegated admin manages AWS Organizations policies", + "Checks": [ + "organizations_delegated_administrators" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that a dedicated member account is configured as a delegated administrator for AWS Organizations to manage organization policies (SCPs, RCPs, tag policies, backup policies, AI opt-out policies) and other Organizations features, instead of performing these tasks directly from the management account. The delegated administrator for AWS Organizations is configured via a resource-based delegation policy in the management account, which grants specific member accounts limited permissions to perform Organizations policy and account management actions across the organization. This allows policy management, OU operations, and other governance tasks to be handled from purpose-built accounts without requiring broad access to the management account.", + "RationaleStatement": "The management account has unique and high privileges to manage AWS Organizations (for example, creating/deleting accounts, managing org structures) and is not subject to guardrails like SCPs. Without a delegated administrator for Organizations, all policy management, OU changes, and account governance must be performed directly from the management account. This results in concentrating operational activity in the most powerful account. Configuring a dedicated member account as a delegated administrator for Organizations policy management distributes these tasks to a purpose-built AWS account that can be protected by SCPs and other controls, reduces the number of users and roles that need management-account access, and supports separation of duties while maintaining centralized control over organization-wide features.", + "ImpactStatement": "Configuring a delegated administrator for AWS Organizations requires creating or identifying a dedicated member account for policy management and granting it specific permissions via a resource-based delegation policy. Existing workflows, automation, and user access patterns that currently perform Organizations policy tasks directly from the management account must be updated to use the delegated account instead. This introduces short-term operational overhead and testing to ensure policy creation, attachment, and management continue to function correctly from the new account.", + "RemediationProcedure": "1. Identify a dedicated member account for governance/policy management (for example, create a new \"Policy Management\" account or use an existing Security account) 2. You must be in the management account with permissions to manage Organizations resource policies. Navigate to AWS Organizations console, then click on Settings and browse to \"Delegated administrator for AWS Organizations\" section. 3. If no policy exists, click on Delegate. If a policy exists, choose Edit policy. - In the policy editor, paste or construct a delegation policy statement mentioning the Principal as the AWS account Root which is being delegated access to, and the Actions with the list of least-privileged permissions that could be performed by the delegated AWS account. - Save and validate the delegation policy. 4. Sign in to the delegated administrator account and open the AWS Organizations console. - Confirm that policy management (Policies, Attach/Detach, etc.) is accessible and that users/roles in this account can perform Organizations tasks without management-account access. 5. Grant IAM roles/users in the delegated admin account only the permissions needed for Organizations policy management. 6. Update procedures so that routine Organizations policy tasks are performed from the delegated account, reserving the management account for tasks that only it can perform", + "AuditProcedure": "1. Sign in to the AWS Organizations console. From the AWS Accounts section, verify that this is the management account for the organization. 2. In the AWS Organizations console, navigate to Settings. Scroll to the Delegated administrator for AWS Organizations section 3. Review the delegation policy status: - If a delegation policy is configured and shows one or more member accounts registered to manage Organizations policies, proceed to step 4. - If no delegation policy is configured or the section shows No delegated administrator (or equivalent), the audit fails because Organizations management is performed directly from the management account. 4. In the Delegated administrator section, note the account IDs registered for Organizations policy management. Confirm that the delegated accounts are purpose-built governance, security, or policy management accounts, not general workload, sandbox, or development accounts. 5. View the delegation policy details to confirm it grants appropriate least-privilege permissions for policy types (for example, SCPs, tag policies, backup policies) and actions (CreatePolicy, AttachPolicy, UpdatePolicy, etc.).", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.6", + "Description": "Ensure delegated admins manage AWS Organizations-integrated services", + "Checks": [ + "organizations_delegated_administrators" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that AWS services (such as AWS CloudTrail) which integrate with AWS Organizations and support delegated administration are managed through delegated administrator member accounts instead of directly from the Organizations management account. For each such service, the management account should enable trusted access and register a purpose-built member account as the delegated administrator, so that this account can perform service-level administration across all organization accounts.", + "RationaleStatement": "The management account has unique and high privileges to manage AWS Organizations (for example, creating/deleting accounts, managing org structures) and is not subject to guardrails like SCPs. Without delegated administrators, organization-wide security, logging, and management services must be operated directly from the management account, concentrating operational activity and credentials in the most privileged account in the organization. Registering member accounts as delegated administrators for AWS services distributes service-specific administration to dedicated security, logging, or operations accounts that can be restricted by SCPs, monitored like other workload accounts, and aligned with team responsibilities, while reducing day-to-day use of the management account.", + "ImpactStatement": "Configuring a delegated administrator for AWS Services that integrate with AWS Organizations requires creating or identifying a dedicated member account for policy management and granting it specific permissions. Existing workflows, automation, and user access patterns that currently perform tasks directly from the management account must be updated to use the delegated account instead. This introduces short-term operational overhead and testing to ensure policy creation, attachment, and management continue to function correctly from the new account.", + "RemediationProcedure": "Note: This remediation section uses AWS CloudTrail as a concrete example. You must perform similar procedure for all other AWS services that integrate with AWS Organizations and support delegated administration that are in use in your environment. 1. In the management account, verify that trusted access for CloudTrail is enabled in AWS Organizations (AWS Organizations → Services). 2. In the management account CloudTrail console, choose Settings in the left navigation pane. Scroll to Organization delegated administrators. 3. Click on \"Register administrator\" - Enter the account ID of the designated Logging or Security account. - Click on Register administrator. CloudTrail will automatically create the necessary service-linked roles and register the account. 4. In the delegated administrator account, open the CloudTrail console and confirm that the organization trail is visible and administrative actions are accessible. 5. Update operational runbooks so that routine CloudTrail administration is performed from the delegated admin account, not the management account.", + "AuditProcedure": "Note: This audit uses AWS CloudTrail as a concrete example. You must perform similar audits for all other AWS services that integrate with AWS Organizations and support delegated administration that are in use in your environment. 1. Sign in to the management account and open the CloudTrail console. 2. In the left navigation pane, choose Trails. - Verify that there is at least one organization trail (trail with Apply trail to all accounts in my organization or equivalent setting enabled) - If CloudTrail is only configured as single-account trails and no organization trail is in use, note that delegated admin for CloudTrail is not in scope and this recommendation is not applicable for CloudTrail in this environment. 3. In the same management account CloudTrail console, choose Settings in the left navigation pane, and scroll to the Organization delegated administrators section. 4. Verify the configuration for Organization delegated administrators: - Verify that at least one member account ID (not the management account) is listed as a delegated administrator for CloudTrail. - Verify that the account(s) are appropriate for security/logging operations (for example, a named Security or Logging account, not a sandbox or general workload account). - If the section shows \"No delegated administrators\" when an organization trail is in use, CloudTrail is effectively administered from the management account and this is a gap.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.2", + "Description": "Maintain current AWS account contact details", + "Checks": [ + "account_maintain_current_contact_details" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure contact email and telephone details for AWS accounts are current and map to more than one individual in your organization. An AWS account supports a number of contact details, and AWS will use these to contact the account owner if activity judged to be in breach of the Acceptable Use Policy or indicative of a likely security compromise is observed by the AWS Abuse team. Contact details should not be for a single individual, as circumstances may arise where that individual is unavailable. Email contact details should point to a mail alias which forwards email to multiple individuals within the organization; where feasible, phone contact details should point to a PABX hunt group or other call-forwarding system.", + "RationaleStatement": "If an AWS account is observed to be behaving in a prohibited or suspicious manner, AWS will attempt to contact the account owner by email and phone using the contact details listed. If this is unsuccessful and the account behavior needs urgent mitigation, proactive measures may be taken, including throttling of traffic between the account exhibiting suspicious behavior and the AWS API endpoints and the Internet. This will result in impaired service to and from the account in question, so it is in both the customers' and AWS's best interests that prompt contact can be established. This is best achieved by setting AWS account contact details to point to resources which have multiple individuals as recipients, such as email aliases and PABX hunt groups.", + "ImpactStatement": "", + "RemediationProcedure": "This activity can only be performed via the AWS Console, with a user who has permission to read and write Billing information (aws-portal:\\*Billing). **From Console:** 1. Sign in to the AWS Management Console and open the `Billing and Cost Management` console at https://console.aws.amazon.com/billing/home#/. 2. On the navigation bar, choose your account name, and then choose `Account`. 3. On the `Account Settings` page, next to `Account Settings`, choose `Edit`. 4. Next to the field that you need to update, choose `Edit`. 5. After you have entered your changes, choose `Save changes`. 6. After you have made your changes, choose `Done`. 7. To edit your contact information, under `Contact Information`, choose `Edit`. 8. For the fields that you want to change, type your updated information, and then choose `Update`. **From Command Line:** 1. Run the following command: ``` aws account put-contact-information --contact-information '{\"AddressLine1\": \"\", \"AddressLine2\": \"\", \"City\": \"\", \"CompanyName\": \"\", \"CountryCode\": \"\", \"FullName\": \"\", \"PhoneNumber\": \"\", \"PostalCode\": \"\", \"StateOrRegion\": \"\"}' ```", + "AuditProcedure": "This activity can only be performed via the AWS Console, with a user who has permission to read and write Billing information (aws-portal:\\*Billing). 1. Sign in to the AWS Management Console and open the `Billing and Cost Management` console at https://console.aws.amazon.com/billing/home#/. 2. On the navigation bar, choose your account name, and then choose `Account`. 3. On the `Account Settings` page, review and verify the current details. 4. Under `Contact Information`, review and verify the current details.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/manage-account-payment.html#contact-info", + "DefaultValue": "By default, AWS account contact information (email and telephone) is set to the values provided at account creation. These usually reference a single individual rather than a shared alias or group contact." + } + ] + }, + { + "Id": "2.3", + "Description": "Ensure security contact information is registered", + "Checks": [ + "account_security_contact_information_is_registered" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "AWS provides customers with the option of specifying the contact information for account's security team. It is recommended that this information be provided.", + "RationaleStatement": "Specifying security-specific contact information will help ensure that security advisories sent by AWS reach the team in your organization that is best equipped to respond to them.", + "ImpactStatement": "", + "RemediationProcedure": "Perform the following to establish security contact information: **From Console:** 1. Click on your account name at the top right corner of the console. 2. From the drop-down menu Click `My Account` 3. Scroll down to the `Alternate Contacts` section 4. Enter contact information in the `Security` section **From Command Line:** Run the following command with the following input parameters: --email-address, --name, and --phone-number. ``` aws account put-alternate-contact --alternate-contact-type SECURITY ``` **Note:** Consider specifying an internal email distribution list to ensure emails are regularly monitored by more than one individual.", + "AuditProcedure": "Perform the following to determine if security contact information is present: **From Console:** 1. Click on your account name at the top right corner of the console 2. From the drop-down menu Click `My Account` 3. Scroll down to the `Alternate Contacts` section 4. Ensure contact information is specified in the `Security` section **From Command Line:** 1. Run the following command: ``` aws account get-alternate-contact --alternate-contact-type SECURITY ``` 2. Ensure proper contact information is specified for the `Security` contact.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.4", + "Description": "Ensure no 'root' user account access key exists", + "Checks": [ + "iam_no_root_access_key" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The 'root' user account is the most privileged user in an AWS account. AWS Access Keys provide programmatic access to a given AWS account. It is recommended that all access keys associated with the 'root' user account be deleted.", + "RationaleStatement": "Deleting access keys associated with the 'root' user account limits vectors by which the account can be compromised. Additionally, deleting the 'root' access keys encourages the creation and use of role based accounts that are least privileged.", + "ImpactStatement": "", + "RemediationProcedure": "Perform the following to delete active 'root' user access keys. **From Console:** 1. Sign in to the AWS Management Console as 'root' and open the IAM console at [https://console.aws.amazon.com/iam/](https://console.aws.amazon.com/iam/). 2. Click on `` at the top right and select `My Security Credentials` from the drop down list. 3. On the pop out screen Click on `Continue to Security Credentials`. 4. Click on `Access Keys` (Access Key ID and Secret Access Key). 5. If there are active keys, under `Status`, click `Delete` (Note: Deleted keys cannot be recovered). Note: While a key can be made inactive, this inactive key will still show up in the CLI command from the audit procedure, and may lead to the root user being falsely flagged as being non-compliant.", + "AuditProcedure": "Perform the following to determine if the 'root' user account has access keys: **From Console:** 1. Login to the AWS Management Console. 2. Click `Services`. 3. Click `IAM`. 4. Click on `Credential Report`. 5. This will download a `.csv` file which contains credential usage for all IAM users within an AWS Account - open this file. 6. For the `` user, ensure the `access_key_1_active` and `access_key_2_active` fields are set to `FALSE`. **From Command Line:** Run the following command: ``` aws iam get-account-summary | grep AccountAccessKeysPresent ``` If no 'root' access keys exist the output will show `AccountAccessKeysPresent: 0,`. If the output shows a 1, then 'root' keys exist and should be deleted.", + "AdditionalInformation": "- IAM User account root for us-gov cloud regions is not enabled by default. However, on request to AWS support enables 'root' access only through access-keys (CLI, API methods) for us-gov cloud region. - Implement regular checks and alerts for any creation of new root access keys to promptly address any unauthorized or accidental creation.", + "References": "http://docs.aws.amazon.com/general/latest/gr/aws-access-keys-best-practices.html:http://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html:http://docs.aws.amazon.com/IAM/latest/APIReference/API_GetAccountSummary.html:https://aws.amazon.com/blogs/security/an-easier-way-to-determine-the-presence-of-aws-account-access-keys/", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.5", + "Description": "Ensure MFA is enabled for the 'root' user account", + "Checks": [ + "iam_root_mfa_enabled" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The 'root' user account is the most privileged user in an AWS account. Multi-factor Authentication (MFA) adds an extra layer of protection on top of a username and password. With MFA enabled, when a user signs in to an AWS website, they will be prompted for their username and password as well as for an authentication code from their AWS MFA device. **Note:** When virtual MFA is used for 'root' accounts, it is recommended that the device used is NOT a personal device, but rather a dedicated mobile device (tablet or phone) that is kept charged and secured, independent of any individual personal devices (non-personal virtual MFA). This lessens the risks of losing access to the MFA due to device loss, device trade-in, or if the individual owning the device is no longer employed at the company. Where an AWS Organization is using centralized root access, root credentials can be removed from member accounts. In that case it is neither possible nor necessary to configure root MFA in the member account.", + "RationaleStatement": "Enabling MFA provides increased security for console access as it requires the authenticating principal to possess a device that emits a time-sensitive key and have knowledge of a credential.", + "ImpactStatement": "", + "RemediationProcedure": "**Note:** To manage MFA devices for the 'root' AWS account, you must use your 'root' account credentials to sign in to AWS. You cannot manage MFA devices for the 'root' account using other credentials. Perform the following to establish MFA for the 'root' user account: 1. Sign in to the AWS Management Console and open the IAM console at [https://console.aws.amazon.com/iam/](https://console.aws.amazon.com/iam/). 2. Choose `Dashboard` , and under `Security Status` , expand `Activate MFA` on your root account. 3. Choose `Activate MFA` 4. In the wizard, choose `A virtual MFA` device and then choose `Next Step` . 5. IAM generates and displays configuration information for the virtual MFA device, including a QR code graphic. The graphic is a representation of the 'secret configuration key' that is available for manual entry on devices that do not support QR codes. 6. Open your virtual MFA application. (For a list of apps that you can use for hosting virtual MFA devices, see [Virtual MFA Applications](http://aws.amazon.com/iam/details/mfa/#Virtual_MFA_Applications).) If the virtual MFA application supports multiple accounts (multiple virtual MFA devices), choose the option to create a new account (a new virtual MFA device). 7. Determine whether the MFA app supports QR codes, and then do one of the following: - Use the app to scan the QR code. For example, you might choose the camera icon or choose an option similar to Scan code, and then use the device's camera to scan the code. - In the Manage MFA Device wizard, choose Show secret key for manual configuration, and then type the secret configuration key into your MFA application. When you are finished, the virtual MFA device starts generating one-time passwords. In the Manage MFA Device wizard, in the Authentication Code 1 box, type the one-time password that currently appears in the virtual MFA device. Wait up to 30 seconds for the device to generate a new one-time password. Then type the second one-time password into the Authentication Code 2 box. Choose Assign Virtual MFA.", + "AuditProcedure": "Perform the following to determine if the 'root' user account is enabled and has MFA setup: **From Console:** 1. Login to the AWS Management Console 2. Click `Services` 3. Click `IAM` 4. Click on `Credential Report` 5. This will download a `.csv` file which contains credential usage for all IAM users within an AWS Account - open this file 6. For the `` user, ensure the `mfa_active` field is set to `TRUE` or the `password_enabled` field is set to `FALSE` **From Command Line:** 1. Run the following command: ``` aws iam get-account-summary | grep AccountMFAEnabled aws iam get-account-summary | grep AccountPasswordPresent ``` 2. Ensure the AccountMFAEnabled property is set to 1 or the AccountPasswordPresent property is set to 0", + "AdditionalInformation": "IAM User account root for us-gov cloud regions does not have console access. This recommendation is not applicable for us-gov cloud regions.", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-user.html#id_root-user_manage_mfa:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html#enable-virt-mfa-for-root:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-enable-root-access.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.6", + "Description": "Ensure hardware MFA is enabled for the 'root' user account", + "Checks": [ + "iam_root_hardware_mfa_enabled" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "The 'root' user account is the most privileged user in an AWS account. MFA adds an extra layer of protection on top of a user name and password. With MFA enabled, when a user signs in to an AWS website, they will be prompted for their user name and password as well as for an authentication code from their AWS MFA device. For Level 2, it is recommended that the 'root' user account be protected with a hardware MFA. Where an AWS Organization is using centralized root access, root credentials can be removed from member accounts. In that case it is neither possible nor necessary to configure root MFA in the member account.", + "RationaleStatement": "A hardware MFA has a smaller attack surface than a virtual MFA. For example, a hardware MFA does not suffer the attack surface introduced by the mobile smartphone on which a virtual MFA resides. **Note**: Using hardware MFA for numerous AWS accounts may create a logistical device management issue. If this is the case, consider implementing this Level 2 recommendation selectively for the highest security AWS accounts, while applying the Level 1 recommendation to the remaining accounts.", + "ImpactStatement": "", + "RemediationProcedure": "**Note:** To manage MFA devices for the AWS 'root' user account, you must use your 'root' account credentials to sign in to AWS. You cannot manage MFA devices for the 'root' account using other credentials. Perform the following to establish a hardware MFA for the 'root' user account: 1. Open the AWS Management Console and sign in using your root user credentials. 2. On the right side of the navigation bar, choose your account name, and choose Security credentials. 3. In the Multi-Factor Authentication (MFA) section, choose Assign MFA device. 4. In the wizard, type a Device name, choose Authenticator app, and then choose Next. IAM generates and displays configuration information for the virtual MFA device, including a QR code graphic. The graphic is a representation of the secret configuration key that is available for manual entry on devices that do not support QR codes. 5. Open the virtual MFA app on the device. If the virtual MFA app supports multiple virtual MFA devices or accounts, choose the option to create a new virtual MFA device or account. 6. The easiest way to configure the app is to use the app to scan the QR code. If you cannot scan the code, you can type the configuration information manually. The QR code and secret configuration key generated by IAM are tied to your AWS account. To use the QR code to configure the virtual MFA device, from the wizard, choose Show QR code. Then follow the app instructions for scanning the code. For example, you might need to choose the camera icon or choose a command like Scan account barcode, and then use the device's camera to scan the QR code. To manual entry secret key on devices, in the Set up device wizard, choose Show secret key, and then type the secret key into your MFA app. 7. In the wizard, in the MFA code 1 box, type the one-time password that currently appears in the virtual MFA device. Wait up to 30 seconds for the device to generate a new one-time password. Then type the second one-time password into the MFA code 2 box. Choose Add MFA. Remediation for this recommendation is not available through AWS CLI.", + "AuditProcedure": "Perform the following to determine if the 'root' user account has a hardware MFA setup: 1. Run the following command to determine if the 'root' account has MFA setup: ``` aws iam get-account-summary | grep \"AccountMFAEnabled\" aws iam get-account-summary | grep \"AccountPasswordPresent\" ``` The `AccountMFAEnabled` property is set to `1` will ensure that the 'root' user account has MFA (Virtual or Hardware) Enabled. `AccountPasswordPresent` set to `0` indicates that the `root` console credential has been removed. If `AccountMFAEnabled` property is set to `0` and `AccountPasswordPresent` is set to `1` the account is not compliant with this recommendation. 2. If `AccountMFAEnabled` property is set to `1`, determine 'root' account has Hardware MFA enabled. Run the following command to list all virtual MFA devices: ``` aws iam list-virtual-mfa-devices ``` If the output contains one MFA with the following Serial Number, it means the MFA is virtual, not hardware and the account is not compliant with this recommendation: `SerialNumber: arn:aws:iam::__:mfa/root-account-mfa-device`", + "AdditionalInformation": "IAM User account 'root' for us-gov cloud regions does not have console access. This control is not applicable for us-gov cloud regions.", + "References": "CCE-78911-5:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_physical.html#enable-hw-mfa-for-root:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-enable-root-access.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/enable-virt-mfa-for-root.html", + "DefaultValue": "By default, the AWS root user does not have a hardware MFA device assigned. MFA must be explicitly configured, and if enabled by default it will be virtual (software-based), not hardware." + } + ] + }, + { + "Id": "2.7", + "Description": "Eliminate use of the 'root' user for administrative and daily tasks", + "Checks": [ + "iam_avoid_root_usage" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "With the creation of an AWS account, a 'root user' is created that cannot be disabled or deleted. That user has unrestricted access to and control over all resources in the AWS account. It is highly recommended that the use of this account be avoided for everyday tasks.", + "RationaleStatement": "The 'root user' has unrestricted access to and control over all account resources. Use of it is inconsistent with the principles of least privilege and separation of duties, and can lead to unnecessary harm due to error or account compromise.", + "ImpactStatement": "", + "RemediationProcedure": "If you find that the 'root' user account is being used for daily activities, including administrative tasks that do not require the 'root' user: 1. Change the 'root' user password. 2. Deactivate or delete any access keys associated with the 'root' user. Remember, anyone who has 'root' user credentials for your AWS account has unrestricted access to and control of all the resources in your account, including billing information.", + "AuditProcedure": "**From Console:** 1. Login to the AWS Management Console at `https://console.aws.amazon.com/iam/`. 2. In the left pane, click `Credential Report`. 3. Click on `Download Report`. 4. Open or Save the file locally. 5. Locate the `` under the user column. 6. Review `password_last_used, access_key_1_last_used_date, access_key_2_last_used_date` to determine when the 'root user' was last used. **From Command Line:** Run the following CLI commands to provide a credential report for determining the last time the 'root user' was used: ``` aws iam generate-credential-report ``` ``` aws iam get-credential-report --query 'Content' --output text | base64 -d | cut -d, -f1,5,11,16 | grep -B1 '' ``` Review `password_last_used`, `access_key_1_last_used_date`, `access_key_2_last_used_date` to determine when the _root user_ was last used. **Note:** There are a few conditions under which the use of the 'root' user account is required. Please see the reference links for all of the tasks that require use of the 'root' user.", + "AdditionalInformation": "The 'root' user for us-gov cloud regions is not enabled by default. However, on request to AWS support, they can enable the 'root' user and grant access only through access-keys (CLI, API methods) for us-gov cloud region. If the 'root' user for us-gov cloud regions is enabled, this recommendation is applicable. Monitoring usage of the 'root' user can be accomplished by implementing recommendation 3.3 Ensure a log metric filter and alarm exist for usage of the 'root' user.", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-user.html:https://docs.aws.amazon.com/general/latest/gr/aws_tasks-that-require-root.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.8", + "Description": "Ensure IAM password policy requires minimum length of 14 or greater", + "Checks": [ + "iam_password_policy_minimum_length_14" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Password policies are, in part, used to enforce password complexity requirements. IAM password policies can be used to ensure passwords are at least a given length. It is recommended that the password policy require a minimum password length 14.", + "RationaleStatement": "Setting a password complexity policy increases account resiliency against brute force login attempts.", + "ImpactStatement": "Enforcing a minimum password length of 14 characters enhances security by making passwords more resistant to brute force attacks. However, it may require users to create longer and potentially more complex passwords, which could impact user convenience.", + "RemediationProcedure": "Perform the following to set the password policy as prescribed: **From Console:** 1. Login to AWS Console (with appropriate permissions to View Identity Access Management Account Settings) 2. Go to IAM Service on the AWS Console 3. Click on Account Settings on the Left Pane 4. Set Minimum password length to `14` or greater. 5. Click Apply password policy **From Command Line:** ``` aws iam update-account-password-policy --minimum-password-length 14 ``` Note: All commands starting with aws iam update-account-password-policy can be combined into a single command.", + "AuditProcedure": "Perform the following to ensure the password policy is configured as prescribed: **From Console:** 1. Login to AWS Console (with appropriate permissions to View Identity Access Management Account Settings) 2. Go to IAM Service on the AWS Console 3. Click on Account Settings on the Left Pane 4. Ensure Minimum password length is set to 14 or greater. **From Command Line:** ``` aws iam get-account-password-policy ``` Ensure the output of the above command includes MinimumPasswordLength: 14 (or higher)", + "AdditionalInformation": "Ensure the password policy also includes requirements for password complexity, such as the inclusion of uppercase letters, lowercase letters, numbers, and special characters: ``` aws iam update-account-password-policy --require-uppercase-characters --require-lowercase-characters --require-numbers --require-symbols ```", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_passwords_account-policy.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#configure-strong-password-policy", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.9", + "Description": "Ensure IAM password policy prevents password reuse", + "Checks": [ + "iam_password_policy_reuse_24" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "IAM password policies can prevent the reuse of a given password by the same user. It is recommended that the password policy prevent the reuse of passwords.", + "RationaleStatement": "Preventing password reuse increases account resiliency against brute force login attempts.", + "ImpactStatement": "", + "RemediationProcedure": "Perform the following to set the password policy as prescribed: **From Console:** 1. Login to AWS Console (with appropriate permissions to View Identity Access Management Account Settings) 2. Go to IAM Service on the AWS Console 3. Click on Account Settings on the Left Pane 4. Check Prevent password reuse 5. Set Number of passwords to remember is set to `24` **From Command Line:** ``` aws iam update-account-password-policy --password-reuse-prevention 24 ``` Note: All commands starting with aws iam update-account-password-policy can be combined into a single command.", + "AuditProcedure": "Perform the following to ensure the password policy is configured as prescribed: **From Console:** 1. Login to AWS Console (with appropriate permissions to View Identity Access Management Account Settings) 2. Go to IAM Service on the AWS Console 3. Click on Account Settings on the Left Pane 4. Ensure Prevent password reuse is checked 5. Ensure Number of passwords to remember is set to 24 **From Command Line:** ``` aws iam get-account-password-policy ``` Ensure the output of the above command includes PasswordReusePrevention: 24", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_passwords_account-policy.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#configure-strong-password-policy", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.10", + "Description": "Ensure multi-factor authentication (MFA) is enabled for all IAM users that have a console password", + "Checks": [ + "iam_user_mfa_enabled_console_access" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Multi-Factor Authentication (MFA) adds an extra layer of authentication assurance beyond traditional credentials. With MFA enabled, when a user signs in to the AWS Console, they will be prompted for their user name and password as well as for an authentication code from their physical or virtual MFA token. It is recommended that MFA be enabled for all accounts that have a console password.", + "RationaleStatement": "Enabling MFA provides increased security for console access as it requires the authenticating principal to possess a device that displays a time-sensitive key and have knowledge of a credential.", + "ImpactStatement": "AWS will soon end support for SMS multi-factor authentication (MFA). New customers are not allowed to use this feature. We recommend that existing customers switch to an alternative method of MFA.", + "RemediationProcedure": "Perform the following to enable MFA: **From Console:** 1. Sign in to the AWS Management Console and open the IAM console at 'https://console.aws.amazon.com/iam/' 2. In the left pane, select `Users`. 3. In the `User Name` list, choose the name of the intended MFA user. 4. Choose the `Security Credentials` tab, and then choose `Manage MFA Device`. 5. In the `Manage MFA Device wizard`, choose `Virtual MFA` device, and then choose `Continue`. IAM generates and displays configuration information for the virtual MFA device, including a QR code graphic. The graphic is a representation of the 'secret configuration key' that is available for manual entry on devices that do not support QR codes. 6. Open your virtual MFA application. (For a list of apps that you can use for hosting virtual MFA devices, see Virtual MFA Applications at https://aws.amazon.com/iam/details/mfa/#Virtual_MFA_Applications). If the virtual MFA application supports multiple accounts (multiple virtual MFA devices), choose the option to create a new account (a new virtual MFA device). 7. Determine whether the MFA app supports QR codes, and then do one of the following: - Use the app to scan the QR code. For example, you might choose the camera icon or choose an option similar to Scan code, and then use the device's camera to scan the code. - In the Manage MFA Device wizard, choose Show secret key for manual configuration, and then type the secret configuration key into your MFA application. When you are finished, the virtual MFA device starts generating one-time passwords. 8. In the `Manage MFA Device wizard`, in the `MFA Code 1 box`, type the `one-time password` that currently appears in the virtual MFA device. Wait up to 30 seconds for the device to generate a new one-time password. Then type the second `one-time password` into the `MFA Code 2 box`. 9. Click `Assign MFA`.", + "AuditProcedure": "Perform the following to determine if a MFA device is enabled for all IAM users having a console password: **From Console:** 1. Open the IAM console at [https://console.aws.amazon.com/iam/](https://console.aws.amazon.com/iam/). 2. In the left pane, select `Users` 3. If the `MFA` or `Password age` columns are not visible in the table, click the gear icon at the upper right corner of the table and ensure a checkmark is next to both, then click `Close`. 4. Ensure that for each user where the `Password age` column shows a password age, the `MFA` column shows `Virtual`, `U2F Security Key`, or `Hardware`. **From Command Line:** 1. Run the following command (OSX/Linux/UNIX) to generate a list of all IAM users along with their password and MFA status: ``` aws iam generate-credential-report ``` ``` aws iam get-credential-report --query 'Content' --output text | base64 -d | cut -d, -f1,4,8 ``` 2. The output of this command will produce a table similar to the following: ``` user,password_enabled,mfa_active elise,false,false brandon,true,true rakesh,false,false helene,false,false paras,true,true anitha,false,false ``` 3. For any column having `password_enabled` set to `true` , ensure `mfa_active` is also set to `true.`", + "AdditionalInformation": "**Forced IAM User Self-Service Remediation** Amazon has published a pattern that requires users to set up MFA through self-service before they gain access to their complete set of permissions. Until they complete this step, they cannot access their full permissions. This pattern can be used for new AWS accounts. It can also be applied to existing accounts; it is recommended that users receive instructions and a grace period to complete MFA enrollment before active enforcement on existing AWS accounts.", + "References": "https://tools.ietf.org/html/rfc6238:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#enable-mfa-for-privileged-users:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html:https://blogs.aws.amazon.com/security/post/Tx2SJJYE082KBUK/How-to-Delegate-Management-of-Multi-Factor-Authentication-to-AWS-IAM-Users", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.11", + "Description": "Ensure credentials unused for 45 days or more are disabled", + "Checks": [ + "iam_user_accesskey_unused", + "iam_user_console_access_unused" + ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "AWS IAM users can access AWS resources using different types of credentials, such as passwords or access keys. It is recommended that all credentials that have been unused for 45 days or more be deactivated or removed.", + "RationaleStatement": "Disabling or removing unnecessary credentials will reduce the window of opportunity for credentials associated with a compromised or abandoned account to be used.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** Perform the following to manage Unused Password (IAM user console access) 1. Login to the AWS Management Console: 2. Click `Services` 3. Click `IAM` 4. Click on `Users` 5. Click on `Security Credentials` 6. Select user whose `Console last sign-in` is greater than 45 days 7. Click `Security credentials` 8. In section `Sign-in credentials`, `Console password` click `Manage` 9. Under Console Access select `Disable` 10. Click `Apply` Perform the following to deactivate Access Keys: 1. Login to the AWS Management Console: 2. Click `Services` 3. Click `IAM` 4. Click on `Users` 5. Click on `Security Credentials` 6. Select any access keys that are over 45 days old and that have been used and - Click on `Make Inactive` 7. Select any access keys that are over 45 days old and that have not been used and - Click the X to `Delete`", + "AuditProcedure": "Perform the following to determine if unused credentials exist: **From Console:** 1. Login to the AWS Management Console 2. Click `Services` 3. Click `IAM` 4. Click on `Users` 5. Click the `Settings` (gear) icon. 6. Select `Console last sign-in`, `Access key last used`, and `Access Key Id` 7. Click on `Close` 8. Check and ensure that `Console last sign-in` is less than 45 days ago. **Note** - `Never` means the user has never logged in. 9. Check and ensure that `Access key age` is less than 45 days and that `Access key last used` does not say `None` If the user hasn't signed into the Console in the last 45 days or Access keys are over 45 days old refer to the remediation. **From Command Line:** **Download Credential Report:** 1. Run the following commands: ``` aws iam generate-credential-report aws iam get-credential-report --query 'Content' --output text | base64 -d | cut -d, -f1,4,5,6,9,10,11,14,15,16 | grep -v '^' ``` **Ensure unused credentials do not exist:** 2. For each user having `password_enabled` set to `TRUE` , ensure `password_last_used_date` is less than `45` days ago. - When `password_enabled` is set to `TRUE` and `password_last_used` is set to `No_Information` , ensure `password_last_changed` is less than 45 days ago. 3. For each user having an `access_key_1_active` or `access_key_2_active` to `TRUE` , ensure the corresponding `access_key_n_last_used_date` is less than `45` days ago. - When a user having an `access_key_x_active` (where x is 1 or 2) to `TRUE` and corresponding access_key_x_last_used_date is set to `N/A`, ensure `access_key_x_last_rotated` is less than 45 days ago.", + "AdditionalInformation": " is excluded in the audit since the root account should not be used for day-to-day business and would likely be unused for more than 45 days.", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#remove-credentials:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_finding-unused.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_passwords_admin-change-user.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.12", + "Description": "Ensure access keys are rotated every 90 days or less", + "Checks": [ + "iam_rotate_access_key_90_days" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Access keys consist of an access key ID and secret access key, which are used to sign programmatic requests that you make to AWS. AWS users need their own access keys to make programmatic calls to AWS from the AWS Command Line Interface (AWS CLI), Tools for Windows PowerShell, the AWS SDKs, or direct HTTP calls using the APIs for individual AWS services. It is recommended that all access keys be rotated regularly.", + "RationaleStatement": "Rotating access keys will reduce the window of opportunity for an access key that is associated with a compromised or terminated account to be used. Access keys should be rotated to ensure that data cannot be accessed with an old key which might have been lost, cracked, or stolen.", + "ImpactStatement": "", + "RemediationProcedure": "Perform the following to rotate access keys: **From Console:** 1. Go to the Management Console (https://console.aws.amazon.com/iam) 2. Click on `Users` 3. Click on `Security Credentials` 4. As an Administrator - Click on `Make Inactive` for keys that have not been rotated in `90` Days 5. As an IAM User - Click on `Make Inactive` or `Delete` for keys which have not been rotated or used in `90` Days 6. Click on `Create Access Key` 7. Update programmatic calls with new Access Key credentials **From Command Line:** 1. While the first access key is still active, create a second access key, which is active by default. Run the following command: ``` aws iam create-access-key --user-name ``` At this point, the user has two active access keys. 2. Update all applications and tools to use the new access key. 3. Determine whether the first access key is still in use by using this command: ``` aws iam get-access-key-last-used --access-key-id ``` 4. One approach is to wait several days and then check the old access key for any use before proceeding. Even if step 3 indicates no use of the old key, it is recommended that you do not immediately delete the first access key. Instead, change the state of the first access key to Inactive using this command: ``` aws iam update-access-key --user-name --access-key-id --status Inactive ``` 5. Use only the new access key to confirm that your applications are working. Any applications and tools that still use the original access key will stop working at this point because they no longer have access to AWS resources. If you find such an application or tool, you can switch its state back to Active to reenable the first access key. Then return to step 2 and update this application to use the new key. 6. After you wait some period of time to ensure that all applications and tools have been updated, you can delete the first access key with this command: ``` aws iam delete-access-key --user-name --access-key-id ```", + "AuditProcedure": "Perform the following to determine if access keys are rotated as prescribed: **From Console:** 1. Go to the Management Console (https://console.aws.amazon.com/iam) 2. Click on `Users` 3. For each user, go to `Security Credentials` 4. Review each key under `Access Keys` 5. For each key that shows `Active` for status, ensure that `Created` is less than or equal to `90 days ago`. **From Command Line:** ``` aws iam generate-credential-report aws iam get-credential-report --query 'Content' --output text | base64 -d ``` The `access_key_1_last_rotated` and the `access_key_2_last_rotated` fields in this file notes the date and time, in ISO 8601 date-time format, when the user's access key was created or last changed. If the user does not have an active access key, the value in this field is N/A (not applicable).", + "AdditionalInformation": "", + "References": "CCE-78902-4:https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#rotate-credentials:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_finding-unused.html:https://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html", + "DefaultValue": "By default, AWS does not enforce access key rotation. Access keys remain valid until they are manually deactivated or deleted." + } + ] + }, + { + "Id": "2.13", + "Description": "Ensure IAM users receive permissions only through groups", + "Checks": [ + "iam_policy_attached_only_to_group_or_roles" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "IAM users are granted access to services, functions, and data through IAM policies. There are four ways to define policies for a user: 1) Edit the user policy directly, also known as an inline or user policy; 2) attach a policy directly to a user; 3) add the user to an IAM group that has an attached policy; 4) add the user to an IAM group that has an inline policy. Only the third implementation is recommended.", + "RationaleStatement": "Assigning IAM policies solely through groups unifies permissions management into a single, flexible layer that is consistent with organizational functional roles. By unifying permissions management, the likelihood of excessive permissions is reduced.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** Perform the following to create an IAM group and assign a policy to it: 1. Sign in to the AWS Management Console and open the IAM console at https://console.aws.amazon.com/iam/. 2. In the navigation pane, click `Groups` and then click `Create New Group`. 3. In the `Group Name` box, type the name of the group and then click `Next Step`. 4. In the list of policies, select the check box for each policy that you want to apply to all members of the group. Then click `Next Step`. 5. Click `Create Group`. Perform the following to add a user to a given group: 1. Sign in to the AWS Management Console and open the IAM console at https://console.aws.amazon.com/iam/. 2. In the navigation pane, click `Groups`. 3. Select the group to add a user to. 4. Click `Add Users To Group`. 5. Select the users to be added to the group. 6. Click `Add Users`. Perform the following to remove a direct association between a user and policy: 1. Sign in to the AWS Management Console and open the IAM console at https://console.aws.amazon.com/iam/. 2. In the left navigation pane, click on Users. 3. For each user: - Select the user - Click on the `Permissions` tab - Expand `Permissions policies` - Click `X` for each policy; then click Detach or Remove (depending on policy type) **From Command Line:** 1. Create the IAM user group: ``` aws iam create-group --group-name ``` 2. Attach the policy to the IAM user group: ``` aws iam attach-group-policy --group-name --policy-arn ``` 3. Perform the following to add a user to a given group: ``` aws iam add-user-to-group --user-name --group-name ``` 4. Perform the following to remove a direct association between a user and policy: ``` aws iam detach-user-policy --user-name --policy-arn ``` 5. Delete an inline policy from an IAM user: ``` aws iam delete-user-policy --user-name --policy-name ```", + "AuditProcedure": "Perform the following to determine if an inline policy is set or a policy is directly attached to users: 1. Run the following to get a list of IAM users: ``` aws iam list-users --query 'Users[*].UserName' --output text ``` 2. For each user returned, run the following command to determine if any policies are attached to them: ``` aws iam list-attached-user-policies --user-name aws iam list-user-policies --user-name ``` 3. If any policies are returned, the user has an inline policy or direct policy attachment.", + "AdditionalInformation": "", + "References": "http://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html:http://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html:CCE-78912-3", + "DefaultValue": "By default, AWS allows IAM policies to be attached directly to users, groups, or roles. There is no restriction preventing direct user policies unless explicitly enforced by organizational standards." + } + ] + }, + { + "Id": "2.14", + "Description": "Ensure IAM policies that allow full \"*:*\" administrative privileges are not attached", + "Checks": [ + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "IAM policies are the means by which privileges are granted to users, groups, or roles. It is recommended and considered standard security advice to grant least privilege—that is, granting only the permissions required to perform a task. Determine what users need to do, and then craft policies for them that allow the users to perform only those tasks, instead of granting full administrative privileges.", + "RationaleStatement": "It's more secure to start with a minimum set of permissions and grant additional permissions as necessary, rather than starting with permissions that are too lenient and then attempting to tighten them later. Providing full administrative privileges instead of restricting access to the minimum set of permissions required for the user exposes resources to potentially unwanted actions. IAM policies that contain a statement with `Effect: Allow` and `Action: *` over `Resource: *` should be removed.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** Perform the following to detach the policy that has full administrative privileges: 1. Sign in to the AWS Management Console and open the IAM console at [https://console.aws.amazon.com/iam/](https://console.aws.amazon.com/iam/). 2. In the navigation pane, click Policies and then search for the policy name found in the audit step. 3. Select the policy that needs to be deleted. 4. In the policy action menu, select `Detach`. 5. Select all Users, Groups, Roles that have this policy attached. 6. Click `Detach Policy`. 7. Select the newly detached policy and select `Delete`. **From Command Line:** Perform the following to detach the policy that has full administrative privileges as found in the audit step: 1. Lists all IAM users, groups, and roles that the specified managed policy is attached to. ``` aws iam list-entities-for-policy --policy-arn ``` 2. Detach the policy from all IAM Users: ``` aws iam detach-user-policy --user-name --policy-arn ``` 3. Detach the policy from all IAM Groups: ``` aws iam detach-group-policy --group-name --policy-arn ``` 4. Detach the policy from all IAM Roles: ``` aws iam detach-role-policy --role-name --policy-arn ```", + "AuditProcedure": "Perform the following to determine existing policies: **From Command Line:** 1. Run the following to get a list of IAM policies: ``` aws iam list-policies --only-attached --output text ``` 2. For each policy returned, run the following command to determine if any policy is allowing full administrative privileges on the account: ``` aws iam get-policy-version --policy-arn --version-id ``` 3. In the output, the policy should not contain any Statement block with `Effect: Allow` and `Action` set to `*` and `Resource` set to `*`.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html:https://docs.aws.amazon.com/cli/latest/reference/iam/index.html#cli-aws-iam", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.15", + "Description": "Ensure a support role has been created to manage incidents with AWS Support", + "Checks": [ + "iam_support_role_created" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "AWS provides a support center that can be used for incident notification and response, as well as technical support and customer services. Create an IAM Role, with the appropriate policy assigned, to allow authorized users to manage incidents with AWS Support.", + "RationaleStatement": "By implementing least privilege for access control, an IAM Role will require an appropriate IAM Policy to allow Support Center Access in order to manage Incidents with AWS Support.", + "ImpactStatement": "All AWS Support plans include an unlimited number of account and billing support cases, with no long-term contracts. Support billing calculations are performed on a per-account basis for all plans. Enterprise Support plan customers have the option to include multiple enabled accounts in an aggregated monthly billing calculation. Monthly charges for the Business and Enterprise support plans are based on each month's AWS usage charges, subject to a monthly minimum, billed in advance. When assigning rights, keep in mind that other policies may grant access to Support as well. This may include AdministratorAccess and other policies including customer managed policies. Utilizing the AWS managed 'AWSSupportAccess' role is one simple way of ensuring that this permission is properly granted. To better support the principle of separation of duties, it would be best to only attach this role where necessary.", + "RemediationProcedure": "**From Command Line:** 1. Create an IAM role for managing incidents with AWS: - Create a trust relationship policy document that allows to manage AWS incidents, and save it locally as /tmp/TrustPolicy.json: ``` { Version: 2012-10-17, Statement: [ { Effect: Allow, Principal: { AWS: }, Action: sts:AssumeRole } ] } ``` 2. Create the IAM role using the above trust policy: ``` aws iam create-role --role-name --assume-role-policy-document file:///tmp/TrustPolicy.json ``` 3. Attach 'AWSSupportAccess' managed policy to the created IAM role: ``` aws iam attach-role-policy --policy-arn arn:aws:iam::aws:policy/AWSSupportAccess --role-name ```", + "AuditProcedure": "**From Command Line:** 1. List IAM policies, filter for the 'AWSSupportAccess' managed policy, and note the Arn element value: ``` aws iam list-policies --query Policies[?PolicyName == 'AWSSupportAccess'] ``` 2. Check if the 'AWSSupportAccess' policy is attached to any role: ``` aws iam list-entities-for-policy --policy-arn arn:aws:iam::aws:policy/AWSSupportAccess ``` 3. In the output, ensure `PolicyRoles` does not return empty. 'Example: Example: PolicyRoles: [ ]' If it returns empty refer to the remediation below.", + "AdditionalInformation": "AWSSupportAccess policy is a global AWS resource. It has same ARN as `arn:aws:iam::aws:policy/AWSSupportAccess` for every account.", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html:https://aws.amazon.com/premiumsupport/pricing/:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/list-policies.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/attach-role-policy.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/list-entities-for-policy.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.16", + "Description": "Ensure IAM instance roles are used for AWS resource access from instances", + "Checks": [ + "ec2_instance_profile_attached" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "AWS access from within AWS instances can be done by either encoding AWS keys into AWS API calls or by assigning the instance to a role which has an appropriate permissions policy for the required access. AWS Access means accessing the APIs of AWS in order to access AWS resources or manage AWS account resources.", + "RationaleStatement": "AWS IAM roles reduce the risks associated with sharing and rotating credentials that can be used outside of AWS itself. Compromised credentials can be used from outside the AWS account to which they provide access. In contrast, to leverage role permissions, an attacker would need to gain and maintain access to a specific instance to use the privileges associated with it. Additionally, if credentials are encoded into compiled applications or other hard-to-change mechanisms, they are even less likely to be properly rotated due to the risks of service disruption. As time passes, credentials that cannot be rotated are more likely to be known by an increasing number of individuals who no longer work for the organization that owns the credentials.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** 1. Sign in to the AWS Management Console and navigate to the EC2 dashboard at `https://console.aws.amazon.com/ec2/`. 2. In the left navigation panel, choose `Instances`. 3. Select the EC2 instance you want to modify. 4. Click `Actions`. 5. Click `Security`. 6. Click `Modify IAM role`. 7. Click `Create new IAM role` if a new IAM role is required. 8. Select the IAM role you want to attach to your instance in the `IAM role` dropdown. 9. Click `Update IAM role`. 10. Repeat steps 3 to 9 for each EC2 instance in your AWS account that requires an IAM role to be attached. **From Command Line:** 1. Run the `describe-instances` command to list all EC2 instance IDs in the selected AWS region: ``` aws ec2 describe-instances --region --query 'Reservations[*].Instances[*].InstanceId' ``` 2. Run the `associate-iam-instance-profile` command to attach an instance profile (which is attached to an IAM role) to the EC2 instance: ``` aws ec2 associate-iam-instance-profile --region --instance-id --iam-instance-profile Name=Instance-Profile-Name ``` 3. Run the `describe-instances` command again for the recently modified EC2 instance. The command output should return the instance profile ARN and ID: ``` aws ec2 describe-instances --region --instance-id --query 'Reservations[*].Instances[*].IamInstanceProfile' ``` 4. Repeat steps 2 and 3 for each EC2 instance in your AWS account that requires an IAM role to be attached.", + "AuditProcedure": "First, check if the instance has any API secrets stored using Secret Scanning. Currently, AWS does not have a solution for this. You can use open-source tools like TruffleHog to scan for secrets in the EC2 instance. If a secret is found, then assign the role to the instance. **From Console:** 1. Sign in to the AWS Management Console and navigate to the EC2 dashboard at `https://console.aws.amazon.com/ec2/`. 2. In the left navigation panel, choose `Instances`. 3. Select the EC2 instance you want to examine. 4. Select `Actions`. 5. Select `View details`. 6. Select `Security` in the lower panel. - If the value for **Instance profile arn** is an instance profile ARN, then an instance profile (that contains an IAM role) is attached. - If the value for **IAM Role** is blank, no role is attached. - If the value for **IAM Role** contains a role, a role is attached. - If the value for **IAM Role** is No roles attached to instance profile: , then an instance profile is attached to the instance, but it does not contain an IAM role. 7. Repeat steps 3 to 6 for each EC2 instance in your AWS account. **From Command Line:** 1. Run the `describe-instances` command to list all EC2 instance IDs in the selected AWS region: ``` aws ec2 describe-instances --region --query 'Reservations[*].Instances[*].InstanceId' ``` 2. Run the `describe-instances` command again for each EC2 instance using the `IamInstanceProfile` identifier in the query filter to check if an IAM role is attached: ``` aws ec2 describe-instances --region --instance-id --query 'Reservations[*].Instances[*].IamInstanceProfile' ``` 3. If an IAM role is attached, the command output will show the IAM instance profile ARN and ID. 4. Repeat steps 2 and 3 for each EC2 instance in your AWS account.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2.html:https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.17", + "Description": "Ensure that all expired SSL/TLS certificates stored in AWS IAM are removed", + "Checks": [ + "iam_no_expired_server_certificates_stored" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "To enable HTTPS connections to your website or application in AWS, you need an SSL/TLS server certificate. You can use AWS Certificate Manager (ACM) or IAM to store and deploy server certificates. Use IAM as a certificate manager only when you must support HTTPS connections in a region that is not supported by ACM. IAM securely encrypts your private keys and stores the encrypted version in IAM SSL certificate storage. IAM supports deploying server certificates in all regions, but you must obtain your certificate from an external provider for use with AWS. You cannot upload an ACM certificate to IAM. Additionally, you cannot manage your certificates from the IAM Console.", + "RationaleStatement": "Removing expired SSL/TLS certificates eliminates the risk that an invalid certificate will be deployed accidentally to a resource such as AWS Elastic Load Balancer (ELB), which can damage the credibility of the application/website behind the ELB. As a best practice, it is recommended to delete expired certificates.", + "ImpactStatement": "Deleting the certificate could have implications for your application if you are using an expired server certificate with Elastic Load Balancing, CloudFront, etc. You must make configurations in the respective services to ensure there is no interruption in application functionality.", + "RemediationProcedure": "**From Console:** Removing expired certificates via AWS Management Console is not currently supported. To delete SSL/TLS certificates stored in IAM through the AWS API, use the Command Line Interface (CLI). **From Command Line:** To delete an expired certificate, run the following command by replacing with the name of the certificate to delete: ``` aws iam delete-server-certificate --server-certificate-name ``` When the preceding command is successful, it does not return any output.", + "AuditProcedure": "**From Console:** Getting the certificate expiration information via the AWS Management Console is not currently supported. To request information about the SSL/TLS certificates stored in IAM through the AWS API, use the Command Line Interface (CLI). **From Command Line:** Run the `list-server-certificates` command to list all the IAM-stored server certificates: ``` aws iam list-server-certificates ``` The command output should return an array that contains all the SSL/TLS certificates currently stored in IAM and their metadata (name, ID, expiration date, etc): ``` { ServerCertificateMetadataList: [ { ServerCertificateId: EHDGFRW7EJFYTE88D, ServerCertificateName: MyServerCertificate, Expiration: 2018-07-10T23:59:59Z, Path: /, Arn: arn:aws:iam::012345678910:server-certificate/MySSLCertificate, UploadDate: 2018-06-10T11:56:08Z } ] } ``` Verify the `ServerCertificateName` and `Expiration` parameter value (expiration date) for each SSL/TLS certificate returned by the list-server-certificates command and determine if there are any expired server certificates currently stored in AWS IAM. If so, use the AWS API to remove them. If this command returns: ``` { { ServerCertificateMetadataList: [] } ``` This means that there are no expired certificates; it **does not** mean that no certificates exist.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_server-certs.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/delete-server-certificate.html", + "DefaultValue": "By default, expired certificates will not be deleted." + } + ] + }, + { + "Id": "2.18", + "Description": "Ensure that IAM External Access Analyzer is enabled for all regions", + "Checks": [ + "accessanalyzer_enabled" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enable the IAM External Access Analyzer regarding all resources in each active AWS region. IAM Access Analyzer is a technology introduced at AWS reinvent 2019. After the Analyzer is enabled in IAM, scan results are displayed on the console showing the accessible resources. Scans show resources that other accounts and federated users can access, such as KMS keys and IAM roles. The results allow you to determine whether an unintended user is permitted, making it easier for administrators to monitor least privilege access. Access Analyzer analyzes only the policies that are applied to resources in the same AWS Region.", + "RationaleStatement": "AWS IAM External Access Analyzer helps you identify the resources in your organization and accounts, such as Amazon S3 buckets or IAM roles, that are shared with external entities. This allows you to identify unintended access to your resources and data. Access Analyzer identifies resources that are shared with external principals by using logic-based reasoning to analyze the resource-based policies in your AWS environment. IAM External Access Analyzer continuously monitors all policies for S3 buckets, IAM roles, KMS (Key Management Service) keys, AWS Lambda functions, Amazon SQS (Simple Queue Service) queues and more", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** Perform the following to enable IAM Access Analyzer for IAM policies: 1. Open the IAM console at `https://console.aws.amazon.com/iam/.` 2. Choose `Access analyzer`. 3. Choose `Create external access analyzer`. 4. On the `Create analyzer` page, confirm that the `Region` displayed is the Region where you want to enable Access Analyzer. 5. Optionally enter a name for the analyzer. 6. Optionally add any tags that you want to apply to the analyzer. 7. Choose `Create Analyzer`. 8. Repeat these step for each active region. **From Command Line:** Run the following command: ``` aws accessanalyzer list-analyzers --type ORGANIZATION ``` Repeat this command for each active region. **Note:** The IAM Access Analyzer is successfully configured only when the account you use has the necessary permissions.", + "AuditProcedure": "**From Console:** 1. Open the IAM console at `https://console.aws.amazon.com/iam/` 2. Under `Access analyzer` choose `Analyzer Settings` 3. On the `Analyzer Settings` page, there will be a list of analyzers. 4. Look for analyzers where the `Finding type` is `External Access`. **From Command Line:** 1. Run the following command: ``` aws accessanalyzer list-analyzers --type ORGANIZATION | grep status ``` 2. Ensure that at least one Analyzer's `status` is set to `ACTIVE`. 3. Repeat the steps above for each active region. If an Access Analyzer is not listed for each region or the status is not set to active refer to the remediation procedure below.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-getting-started.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/accessanalyzer/get-analyzer.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/accessanalyzer/create-analyzer.html", + "DefaultValue": "By default, IAM External Access Analyzer is not enabled in any region. An analyzer must be explicitly created and activated for each region where monitoring is required." + } + ] + }, + { + "Id": "2.19", + "Description": "Ensure IAM users are managed centrally via identity federation or AWS Organizations for multi-account environments", + "Checks": [ + "iam_check_saml_providers_sts" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "In multi-account environments, IAM user centralization facilitates greater user control. User access beyond the initial account is then provided via role assumption. Centralization of users can be accomplished through federation with an external identity provider or through the use of AWS Organizations.", + "RationaleStatement": "Centralizing IAM user management to a single identity store reduces complexity and thus the likelihood of access management errors.", + "ImpactStatement": "", + "RemediationProcedure": "The remediation procedure will vary based on each individual organization's implementation of identity federation and/or AWS Organizations, with the acceptance criteria that no non-service IAM users and non-root accounts are present outside the account providing centralized IAM user management.", + "AuditProcedure": "For multi-account AWS environments with an external identity provider: 1. Determine the master account for identity federation or IAM user management 2. Login to that account through the AWS Management Console 3. Click `Services` 4. Click `IAM` 5. Click `Identity providers` 6. Verify the configuration For multi-account AWS environments with an external identity provider, as well as for those implementing AWS Organizations without an external identity provider: 1. Determine all accounts that should not have local users present 2. Log into the AWS Management Console 3. Switch role into each identified account 4. Click `Services` 5. Click `IAM` 6. Click `Users` 7. Confirm that no IAM users representing individuals are present", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.20", + "Description": "Ensure access to AWSCloudShellFullAccess is restricted", + "Checks": [ + "iam_policy_cloudshell_admin_not_attached" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "AWS CloudShell is a convenient way of running CLI commands against AWS services; a managed IAM policy ('AWSCloudShellFullAccess') provides full access to CloudShell, which allows file upload and download capability between a user's local system and the CloudShell environment. Within the CloudShell environment, a user has sudo permissions and can access the internet. Therefore, it is feasible to install file transfer software, for example, and move data from CloudShell to external internet servers.", + "RationaleStatement": "Access to this policy should be restricted, as it presents a potential channel for data exfiltration by malicious cloud admins who are given full permissions to the service. AWS documentation describes how to create a more restrictive IAM policy that denies file transfer permissions.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console** 1. Open the IAM console at https://console.aws.amazon.com/iam/ 2. In the left pane, select Policies 3. Search for and select AWSCloudShellFullAccess 4. On the Entities attached tab, for each item, check the box and select Detach", + "AuditProcedure": "**From Console** 1. Open the IAM console at https://console.aws.amazon.com/iam/ 2. In the left pane, select Policies 3. Search for and select AWSCloudShellFullAccess 4. On the Entities attached tab, ensure that there are no entities using this policy **From Command Line** 1. List IAM policies, filter for the 'AWSCloudShellFullAccess' managed policy, and note the Arn element value: ``` aws iam list-policies --query Policies[?PolicyName == 'AWSCloudShellFullAccess'] ``` 2. Check if the 'AWSCloudShellFullAccess' policy is attached to any role: ``` aws iam list-entities-for-policy --policy-arn arn:aws:iam::aws:policy/AWSCloudShellFullAccess ``` 3. In the output, ensure PolicyRoles returns empty. 'Example: Example: PolicyRoles: [ ]' If it does not return empty, refer to the remediation below. **Note:** Keep in mind that other policies may grant access.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/cloudshell/latest/userguide/sec-auth-with-identities.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.21", + "Description": "Ensure AWS resource policies do not allow unrestricted access using 'Principal': '*'", + "Checks": [ + "s3_bucket_policy_public_write_access", + "sqs_queues_not_publicly_accessible", + "sns_topics_not_publicly_accessible", + "awslambda_function_not_publicly_accessible", + "kms_key_not_publicly_accessible", + "glacier_vaults_policy_public_access", + "secretsmanager_not_publicly_accessible", + "eventbridge_bus_exposed" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure AWS resource-based policies, such as Amazon S3 bucket policies, Amazon SQS queue policies, Amazon SNS topic policies, and AWS Lambda resource policies, do not grant unrestricted access using \"Principal\": \"*\" with \"Effect\": \"Allow\" unless the policy includes restrictive conditions that limit access to specific trusted identities, accounts, services, or network boundaries.", + "RationaleStatement": "Resource-based policies are evaluated alongside identity-based IAM policies during authorization decisions. When a policy statement specifies \"Principal\": \"*\" with \"Effect\": \"Allow\", it grants the specified permissions to any AWS principal unless additional conditions restrict the request. This may unintentionally allow access from users, roles, or services in any AWS account. Such broad access significantly increases the risk of unauthorized data access, resource abuse, or data exfiltration.", + "ImpactStatement": "Unrestricted resource-based policies may expose data or services to unauthorized access, potentially leading to data breaches, service misuse, or unintended public exposure.", + "RemediationProcedure": "If a resource policy contains `\"Principal\": \"*\"` with `\"Effect\": \"Allow\"` and lacks sufficient restrictions, modify the policy to limit access. **OPTION 1 - Restrict the Principal:** Replace the wildcard principal (`\"Principal\": \"*\"`) with a specific account, role, user, or service. Example Non-Compliant Policy: ``` {\"Version\": \"2012-10-17\", \"Statement\": [{\"Sid\": \"AllowPublicAccess\", \"Effect\": \"Allow\", \"Principal\": \"*\", \"Action\": \"sqs:SendMessage\", \"Resource\": \"arn:aws:sqs:us-east-1:123456789012:my-queue\"}]} ``` Steps: 1. Retrieve the current policy: ``` aws sqs get-queue-attributes --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/my-queue --attribute-names Policy --query 'Attributes.Policy' ``` 2. Update the policy with a specific principal: ``` aws sqs set-queue-attributes --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/my-queue --attributes '{\"Policy\": \"{\\\"Version\\\":\\\"2012-10-17\\\",\\\"Statement\\\":[{\\\"Sid\\\":\\\"AllowSpecificAccount\\\",\\\"Effect\\\":\\\"Allow\\\",\\\"Principal\\\":{\\\"AWS\\\":\\\"arn:aws:iam::345678901234:root\\\"},\\\"Action\\\":\\\"sqs:SendMessage\\\",\\\"Resource\\\":\\\"arn:aws:sqs:us-east-1:123456789012:my-queue\\\"}]}\"}' ``` Resulting Compliant Policy: ``` {\"Version\": \"2012-10-17\", \"Statement\": [{\"Sid\": \"AllowSpecificAccount\", \"Effect\": \"Allow\", \"Principal\": {\"AWS\": \"arn:aws:iam::345678901234:root\"}, \"Action\": \"sqs:SendMessage\", \"Resource\": \"arn:aws:sqs:us-east-1:123456789012:my-queue\"}]} ``` **OPTION 2 - Restrict Using Conditions:** If a wildcard principal is required, add restrictive conditions. Example compliant policy: ``` {\"Version\": \"2012-10-17\", \"Statement\": [{\"Sid\": \"AllowServiceIntegration\", \"Effect\": \"Allow\", \"Principal\": \"*\", \"Action\": \"sqs:SendMessage\", \"Resource\": \"arn:aws:sqs:us-east-1:123456789012:my-queue\", \"Condition\": {\"StringEquals\": {\"aws:SourceAccount\": \"345678901234\"}}}]} ```", + "AuditProcedure": "1. Identify resources that support resource-based policies within the AWS account, such as S3 buckets, SQS queues, SNS topics, and Lambda functions. 2. Retrieve the resource policies for each resource. Example CLI commands: SQS Queue Policies: ``` aws sqs get-queue-attributes --queue-url https://sqs.region.amazonaws.com/account/QUEUE --attribute-names Policy ``` S3 Bucket Policies: ``` aws s3api get-bucket-policy --bucket YOUR-BUCKET-NAME ``` SNS Topic Policies: ``` aws sns get-topic-attributes --topic-arn TOPIC-ARN --query \"Attributes.Policy\" --output text ``` 3. Inspect the retrieved policies and identify statements containing: - `\"Effect\": \"Allow\"` AND `\"Principal\": \"*\"` OR - `\"Principal\": {\"AWS\": \"*\"}` 4. Evaluate whether the statement includes restrictive conditions such as: - `aws:SourceArn` - `aws:SourceAccount` - `aws:PrincipalArn` - Other service-specific condition keys 5. Determine audit status: - Compliant: Wildcard principals are present only when restrictive conditions limit access to trusted principals or services - Non-Compliant: Wildcard principals are used without sufficient restrictions", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, AWS does not prevent the use of \"Principal\": \"*\" in resource-based policies. Policies may allow unrestricted access unless explicitly restricted through policy definitions or organizational controls. It is the responsibility of the customer to ensure that resource policies are properly scoped and do not grant unintended public or cross-account access." + } + ] + }, + { + "Id": "3.1.1", + "Description": "Ensure S3 Bucket Policy is set to deny HTTP requests", + "Checks": [ + "s3_bucket_secure_transport_policy" + ], + "Attributes": [ + { + "Section": "3 Storage", + "SubSection": "3.1 Simple Storage Service (S3)", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "At the Amazon S3 bucket level, you can configure permissions through a bucket policy, making the objects accessible only through HTTPS.", + "RationaleStatement": "By default, Amazon S3 allows both HTTP and HTTPS requests. To ensure that access to Amazon S3 objects is only permitted through HTTPS, you must explicitly deny HTTP requests. Bucket policies that allow HTTPS requests without explicitly denying HTTP requests will not comply with this recommendation.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** 1. Login to the AWS Management Console and open the Amazon S3 console using https://console.aws.amazon.com/s3/. 2. Select the check box next to the Bucket. 3. Click on 'Permissions'. 4. Click 'Bucket Policy'. 5. Add either of the following to the existing policy, filling in the required information: ``` { Sid: , Effect: Deny, Principal: *, Action: s3:*, Resource: arn:aws:s3:::/*, Condition: { Bool: { aws:SecureTransport: false } } } ``` or ``` { Sid: , Effect: Deny, Principal: *, Action: s3:*, Resource: [ arn:aws:s3:::, arn:aws:s3:::/* ], Condition: { NumericLessThan: { s3:TlsVersion: 1.2 } } } ``` 6. Save 7. Repeat for all the buckets in your AWS account that contain sensitive data. **From Console** Using AWS Policy Generator: 1. Repeat steps 1-4 above. 2. Click on `Policy Generator` at the bottom of the Bucket Policy Editor. 3. Select Policy Type `S3 Bucket Policy`. 4. Add Statements: - `Effect` = Deny - `Principal` = * - `AWS Service` = Amazon S3 - `Actions` = * - `Amazon Resource Name` = 5. Generate Policy. 6. Copy the text and add it to the Bucket Policy. **From Command Line:** 1. Export the bucket policy to a json file: ``` aws s3api get-bucket-policy --bucket --query Policy --output text > policy.json ``` 2. Modify the policy.json file by adding either of the following: ``` { Sid: , Effect: Deny, Principal: *, Action: s3:*, Resource: arn:aws:s3:::/*, Condition: { Bool: { aws:SecureTransport: false } } } ``` or ``` { Sid: , Effect: Deny, Principal: *, Action: s3:*, Resource: [ arn:aws:s3:::, arn:aws:s3:::/* ], Condition: { NumericLessThan: { s3:TlsVersion: 1.2 } } } ``` 3. Apply this modified policy back to the S3 bucket: ``` aws s3api put-bucket-policy --bucket --policy file://policy.json ```", + "AuditProcedure": "To allow access to HTTPS, you can use a bucket policy with the effect `allow` and a condition that checks for the key `aws:SecureTransport: true`. This means that HTTPS requests are allowed, but it does not deny HTTP requests. To explicitly deny HTTP access, ensure that there is also a bucket policy with the effect `deny` that contains the key `aws:SecureTransport: false`. You may also require TLS by setting a policy to deny any version lower than the one you wish to require, using the condition `NumericLessThan` and the key `s3:TlsVersion: 1.2`. **From Console:** 1. Login to the AWS Management Console and open the Amazon S3 console using https://console.aws.amazon.com/s3/. 2. Select the check box next to the Bucket. 3. Click on 'Permissions', then click on `Bucket Policy`. 4. Ensure that a policy is listed that matches either: ``` { Sid: , Effect: Deny, Principal: *, Action: s3:*, Resource: arn:aws:s3:::/*, Condition: { Bool: { aws:SecureTransport: false } } } ``` or ``` { Sid: , Effect: Deny, Principal: *, Action: s3:*, Resource: [ arn:aws:s3:::, arn:aws:s3:::/* ], Condition: { NumericLessThan: { s3:TlsVersion: 1.2 } } } ``` `` and `` will be specific to your account, and TLS version will be site/policy specific to your organisation. 5. Repeat for all the buckets in your AWS account. **From Command Line:** 1. List all of the S3 Buckets ``` aws s3 ls ``` 2. Using the list of buckets, run this command on each of them: ``` aws s3api get-bucket-policy --bucket | grep aws:SecureTransport ``` or ``` aws s3api get-bucket-policy --bucket | grep s3:TlsVersion ``` NOTE : If an error is thrown by the CLI, it means no policy has been configured for the specified S3 bucket, and that by default it is allowing both HTTP and HTTPS requests. 3. Confirm that `aws:SecureTransport` is set to false (such as `aws:SecureTransport:false`) or that `s3:TlsVersion` has a site-specific value. 4. Confirm that the policy line has Effect set to Deny 'Effect:Deny'", + "AdditionalInformation": "", + "References": "https://aws.amazon.com/premiumsupport/knowledge-center/s3-bucket-policy-for-config-rule/:https://aws.amazon.com/blogs/security/how-to-use-bucket-policies-and-apply-defense-in-depth-to-help-secure-your-amazon-s3-data/:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/s3api/get-bucket-policy.html", + "DefaultValue": "Both HTTP and HTTPS requests are allowed." + } + ] + }, + { + "Id": "3.1.2", + "Description": "Ensure MFA Delete is enabled on S3 buckets", + "Checks": [ + "s3_bucket_no_mfa_delete" + ], + "Attributes": [ + { + "Section": "3 Storage", + "SubSection": "3.1 Simple Storage Service (S3)", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Once MFA Delete is enabled on your sensitive and classified S3 bucket, it requires the user to provide two forms of authentication.", + "RationaleStatement": "Adding MFA delete to an S3 bucket requires additional authentication when you change the version state of your bucket or delete an object version, adding another layer of security in the event your security credentials are compromised or unauthorized access is granted.", + "ImpactStatement": "Enabling MFA delete on an S3 bucket could require additional administrator oversight. Enabling MFA delete may impact other services that automate the creation and/or deletion of S3 buckets.", + "RemediationProcedure": "Perform the steps below to enable MFA delete on an S3 bucket: **Note:** - You cannot enable MFA Delete using the AWS Management Console; you must use the AWS CLI or API. - You must use your 'root' account to enable MFA Delete on S3 buckets. **From Command line:** 1. Run the s3api `put-bucket-versioning` command: ``` aws s3api put-bucket-versioning --profile my-root-profile --bucket Bucket_Name --versioning-configuration Status=Enabled,MFADelete=Enabled --mfa “arn:aws:iam::aws_account_id:mfa/root-account-mfa-device passcode” ```", + "AuditProcedure": "Perform the steps below to confirm that MFA delete is configured on an S3 bucket: **From Console:** 1. Login to the S3 console at `https://console.aws.amazon.com/s3/`. 2. Click the `check` box next to the name of the bucket you want to confirm. 3. In the window under `Properties`: - Confirm that Versioning is `Enabled` - Confirm that MFA Delete is `Enabled` **From Command Line:** 1. Run the `get-bucket-versioning` command: ``` aws s3api get-bucket-versioning --bucket my-bucket ``` Example output: ``` Enabled Enabled ``` If the console or CLI output does not show that Versioning and MFA Delete are `enabled`, please refer to the remediation below.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AmazonS3/latest/dev/Versioning.html#MultiFactorAuthenticationDelete:https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMFADelete.html:https://aws.amazon.com/blogs/security/securing-access-to-aws-using-mfa-part-3/:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_lost-or-broken.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.3", + "Description": "Ensure all data in Amazon S3 has been discovered, classified, and secured when necessary", + "Checks": [ + "macie_is_enabled" + ], + "Attributes": [ + { + "Section": "3 Storage", + "SubSection": "3.1 Simple Storage Service (S3)", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Amazon S3 buckets can contain sensitive data that, for security purposes, should be discovered, monitored, classified, and protected. Macie, along with other third-party tools, can automatically provide an inventory of Amazon S3 buckets.", + "RationaleStatement": "Using a cloud service or third-party software to continuously monitor and automate the process of data discovery and classification for S3 buckets through machine learning and pattern matching is a strong defense in protecting that information. Amazon Macie is a fully managed data security and privacy service that uses machine learning and pattern matching to discover and protect your sensitive data in AWS.", + "ImpactStatement": "There is a cost associated with using Amazon Macie, and there is typically a cost associated with third-party tools that perform similar processes and provide protection.", + "RemediationProcedure": "Perform the steps below to enable and configure Amazon Macie: **From Console:** 1. Log on to the Macie console at `https://console.aws.amazon.com/macie/`. 2. Click `Get started`. 3. Click `Enable Macie`. Set up a repository for sensitive data discovery results: 1. In the left pane, under Settings, click `Discovery results`. 2. Make sure `Create bucket` is selected. 3. Create a bucket and enter a name for it. The name must be unique across all S3 buckets, and it must start with a lowercase letter or a number. 4. Click `Advanced`. 5. For block all public access, make sure `Yes` is selected. 6. For KMS encryption, specify the AWS KMS key that you want to use to encrypt the results. The key must be a symmetric customer master key (CMK) that is in the same region as the S3 bucket. 7. Click `Save`. Create a job to discover sensitive data: 1. In the left pane, click `S3 buckets`. Macie displays a list of all the S3 buckets for your account. 2. Check the box for each bucket that you want Macie to analyze as part of the job. 3. Click `Create job`. 4. Click `Quick create`. 5. For the Name and Description step, enter a name and, optionally, a description of the job. 6. Click `Next`. 7. For the Review and create step, click `Submit`. Review your findings: 1. In the left pane, click `Findings`. 2. To view the details of a specific finding, choose any field other than the check box for the finding. If you are using a third-party tool to manage and protect your S3 data, follow the vendor documentation for implementing and configuring that tool.", + "AuditProcedure": "Perform the following steps to determine if Macie is running: **From Console:** 1. Login to the Macie console at https://console.aws.amazon.com/macie/. 2. In the left hand pane, click on `By job` under findings. 3. Confirm that you have a job set up for your S3 buckets. When you log into the Macie console, if you are not taken to the summary page and do not have a job set up and running, then refer to the remediation procedure below. If you are using a third-party tool to manage and protect your S3 data, you meet this recommendation.", + "AdditionalInformation": "", + "References": "https://aws.amazon.com/macie/getting-started/:https://docs.aws.amazon.com/workspaces/latest/adminguide/data-protection.html:https://docs.aws.amazon.com/macie/latest/user/data-classification.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.4", + "Description": "Ensure that S3 is configured with 'Block Public Access' enabled", + "Checks": [ + "s3_bucket_level_public_access_block", + "s3_account_level_public_access_blocks" + ], + "Attributes": [ + { + "Section": "3 Storage", + "SubSection": "3.1 Simple Storage Service (S3)", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Amazon S3 provides `Block public access (bucket settings)` and `Block public access (account settings)` to help you manage public access to Amazon S3 resources. By default, S3 buckets and objects are created with public access disabled. However, an IAM principal with sufficient S3 permissions can enable public access at the bucket and/or object level. While enabled, `Block public access (bucket settings)` prevents an individual bucket and its contained objects from becoming publicly accessible. Similarly, `Block public access (account settings)` prevents all buckets and their contained objects from becoming publicly accessible across the entire account.", + "RationaleStatement": "Amazon S3 `Block public access (bucket settings)` prevents the accidental or malicious public exposure of data contained within the respective bucket(s). Amazon S3 `Block public access (account settings)` prevents the accidental or malicious public exposure of data contained within all buckets of the respective AWS account. Whether to block public access to all or some buckets is an organizational decision that should be based on data sensitivity, least privilege, and use case.", + "ImpactStatement": "When you apply Block Public Access settings to an account, the settings apply to all AWS regions globally. The settings may not take effect in all regions immediately or simultaneously, but they will eventually propagate to all regions.", + "RemediationProcedure": "**If utilizing Block Public Access (bucket settings)** **From Console:** 1. Login to the AWS Management Console and open the Amazon S3 console using https://console.aws.amazon.com/s3/. 2. Select the check box next to a bucket. 3. Click 'Edit public access settings'. 4. Click 'Block all public access' 5. Repeat for all the buckets in your AWS account that contain sensitive data. **From Command Line:** 1. List all of the S3 buckets: ``` aws s3 ls ``` 2. Enable Block Public Access on a specific bucket: ``` aws s3api put-public-access-block --bucket --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true ``` **If utilizing Block Public Access (account settings)** **From Console:** If the output reads `true` for the separate configuration settings, then Block Public Access is enabled on the account. 1. Login to the AWS Management Console and open the Amazon S3 console using https://console.aws.amazon.com/s3/. 2. Click `Block Public Access (account settings)`. 3. Click `Edit` to change the block public access settings for all the buckets in your AWS account. 4. Update the settings and click `Save`. For details about each setting, pause on the `i` icons. 5. When you're asked for confirmation, enter `confirm`. Then click `Confirm` to save your changes. **From Command Line:** To enable Block Public Access for this account, run the following command: ``` aws s3control put-public-access-block --public-access-block-configuration BlockPublicAcls=true, IgnorePublicAcls=true, BlockPublicPolicy=true, RestrictPublicBuckets=true --account-id ```", + "AuditProcedure": "**If utilizing Block Public Access (bucket settings)** **From Console:** 1. Login to the AWS Management Console and open the Amazon S3 console using https://console.aws.amazon.com/s3/. 2. Select the check box next to a bucket. 3. Click on 'Edit public access settings'. 4. Ensure that the block public access settings are configured appropriately for this bucket. 5. Repeat for all the buckets in your AWS account. **From Command Line:** 1. List all of the S3 buckets: ``` aws s3 ls ``` 2. Find the public access settings for a specific bucket: ``` aws s3api get-public-access-block --bucket ``` Output if Block Public Access is enabled: ``` { PublicAccessBlockConfiguration: { BlockPublicAcls: true, IgnorePublicAcls: true, BlockPublicPolicy: true, RestrictPublicBuckets: true } } ``` If the output reads `false` for the separate configuration settings, then proceed with the remediation. **If utilizing Block Public Access (account settings)** **From Console:** 1. Login to the AWS Management Console and open the Amazon S3 console using https://console.aws.amazon.com/s3/. 2. Choose `Block public access (account settings)`. 3. Ensure that the block public access settings are configured appropriately for your AWS account. **From Command Line:** To check the block public access settings for this account, run the following command: `aws s3control get-public-access-block --account-id --region ` Output if Block Public Access is enabled: ``` { PublicAccessBlockConfiguration: { IgnorePublicAcls: true, BlockPublicPolicy: true, BlockPublicAcls: true, RestrictPublicBuckets: true } } ``` If the output reads `false` for the separate configuration settings, then proceed with the remediation.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AmazonS3/latest/user-guide/block-public-access-account.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.2.1", + "Description": "Ensure that encryption-at-rest is enabled for RDS instances", + "Checks": [ + "rds_instance_storage_encrypted" + ], + "Attributes": [ + { + "Section": "3 Storage", + "SubSection": "3.2 Relational Database Service (RDS)", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Amazon RDS encrypted DB instances use the industry-standard AES-256 encryption algorithm to encrypt your data on the server that hosts your Amazon RDS DB instances. After your data is encrypted, Amazon RDS handles the authentication of access and the decryption of your data transparently, with minimal impact on performance.", + "RationaleStatement": "Databases are likely to hold sensitive and critical data; therefore, it is highly recommended to implement encryption to protect your data from unauthorized access or disclosure. With RDS encryption enabled, the data stored on the instance's underlying storage, the automated backups, read replicas, and snapshots are all encrypted.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** 1. Login to the AWS Management Console and open the RDS dashboard at https://console.aws.amazon.com/rds/. 2. In the left navigation panel, click on `Databases`. 3. Select the Database instance that needs to be encrypted. 4. Click the `Actions` button placed at the top right and select `Take Snapshot`. 5. On the Take Snapshot page, enter the name of the database for which you want to take a snapshot in the `Snapshot Name` field and click on `Take Snapshot`. 6. Select the newly created snapshot, click the `Action` button placed at the top right, and select `Copy snapshot` from the Action menu. 7. On the Make Copy of DB Snapshot page, perform the following: - In the `New DB Snapshot Identifier` field, enter a name for the new snapshot. - Check `Copy Tags`. The new snapshot must have the same tags as the source snapshot. - Select `Yes` from the `Enable Encryption` dropdown list to enable encryption. You can choose to use the AWS default encryption key or a custom key from the Master Key dropdown list. 8. Click `Copy Snapshot` to create an encrypted copy of the selected instance's snapshot. 9. Select the new Snapshot Encrypted Copy and click the `Action` button located at the top right. Then, select the `Restore Snapshot` option from the Action menu. This will restore the encrypted snapshot to a new database instance. 10. On the Restore DB Instance page, enter a unique name for the new database instance in the DB Instance Identifier field. 11. Review the instance configuration details and click `Restore DB Instance`. 12. As the new instance provisioning process is completed, you can update the application configuration to refer to the endpoint of the new encrypted database instance. Once the database endpoint is changed at the application level, you can remove the unencrypted instance. **From Command Line:** 1. Run the `describe-db-instances` command to list the names of all RDS database instances in the selected AWS region. The command output should return database instance identifiers: ``` aws rds describe-db-instances --query 'DBInstances[*].DBInstanceIdentifier' ``` 2. Check if the specified RDS instance is encrypted. If it shows false, it means it is not yet encrypted: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].StorageEncrypted' ``` 3. Run the `create-db-snapshot` command to create a snapshot for a selected database instance. The command output will return the `new snapshot` with name DB Snapshot Name: ``` aws rds create-db-snapshot --region --db-snapshot-identifier --db-instance-identifier ``` 4. Now run the `list-aliases` command to list the KMS key aliases available in a specified region. The command output should return each `key alias currently available`. For our RDS encryption activation process, locate the ID of the AWS default KMS key: ``` aws kms list-aliases --region ``` 5. Run the `copy-db-snapshot` command using the default KMS key ID for the RDS instances returned earlier to create an encrypted copy of the database instance snapshot. The command output will return the `encrypted instance snapshot configuration`: ``` aws rds copy-db-snapshot --region --source-db-snapshot-identifier --target-db-snapshot-identifier --copy-tags --kms-key-id ``` 6. Run the `restore-db-instance-from-db-snapshot` command to restore the encrypted snapshot created in the previous step to a new database instance. If successful, the command output should return the configuration of the new encrypted database instance. If using the default VPC for the database network: ``` aws rds restore-db-instance-from-db-snapshot --region --db-instance-identifier --db-snapshot-identifier ``` If you created your own VPC and Subnets, you need to create a DB subnet group: ``` aws rds create-db-subnet-group --db-subnet-group-name --db-subnet-group-description --subnet-ids '[\"\",\"\",\"\"]' ``` Restore the encrypted snapshot to an RDS database instance using the specified DB subnet group. The new instance will be encrypted using the KMS key specified during the snapshot copy: ``` aws rds restore-db-instance-from-db-snapshot --region --db-subnet-group-name --db-instance-identifier --db-snapshot-identifier ``` 7. Run the `describe-db-instances` command to list all RDS database names available in the selected AWS region. The output will return the database instance identifier names. Select the encrypted database name that we just created, `db-name-encrypted`: ``` aws rds describe-db-instances --region --query 'DBInstances[*].DBInstanceIdentifier' ``` 8. Run the `describe-db-instances` command again using the RDS instance identifier returned earlier to determine if the selected database instance is encrypted. The command output should indicate that the encryption status is `True`: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].StorageEncrypted' ```", + "AuditProcedure": "**From Console:** 1. Login to the AWS Management Console and open the RDS dashboard at https://console.aws.amazon.com/rds/. 2. In the navigation pane, under RDS dashboard, click `Databases`. 3. Select the RDS instance that you want to examine. 4. Click `Instance Name` to see details, then select the `Configuration` tab. 5. Under Configuration Details, in the Storage pane, search for the `Encryption Enabled` status. 6. If the current status is set to `Disabled`, encryption is not enabled for the selected RDS database instance. 7. Repeat steps 2 to 6 to verify the encryption status of other RDS instances in the same region. 8. Change the region from the top of the navigation bar, and repeat the audit steps for other regions. **From Command Line:** 1. Run the `describe-db-instances` command to list all the RDS database instance names available in the selected AWS region. The output will return each database instance identifier (name): ``` aws rds describe-db-instances --region --query 'DBInstances[*].DBInstanceIdentifier' ``` 2. Run the `describe-db-instances` command again, using an RDS instance identifier returned from step 1, to determine if the selected database instance is encrypted. The output should return the encryption status `True` or `False`: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].StorageEncrypted' ``` 3. If the StorageEncrypted parameter value is `False`, encryption is not enabled for the selected RDS database instance. 4. Repeat steps 1 to 3 to audit each RDS instance, and change the region to verify RDS instances in other regions.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Overview.Encryption.html:https://aws.amazon.com/blogs/database/selecting-the-right-encryption-options-for-amazon-rds-and-amazon-aurora-database-engines/#:~:text=With%20RDS%2Dencrypted%20resources%2C%20data,transparent%20to%20your%20database%20engine.:https://aws.amazon.com/rds/features/security/:https://docs.aws.amazon.com/cli/latest/reference/rds/create-db-subnet-group.html", + "DefaultValue": "By default, Amazon RDS instances are created without encryption at rest. Encryption must be explicitly enabled at instance creation or by restoring from an encrypted snapshot." + } + ] + }, + { + "Id": "3.2.2", + "Description": "Ensure the Auto Minor Version Upgrade feature is enabled for RDS instances", + "Checks": [ + "rds_instance_minor_version_upgrade_enabled" + ], + "Attributes": [ + { + "Section": "3 Storage", + "SubSection": "3.2 Relational Database Service (RDS)", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that RDS database instances have the Auto Minor Version Upgrade flag enabled to automatically receive minor engine upgrades during the specified maintenance window. This way, RDS instances can obtain new features, bug fixes, and security patches for their database engines.", + "RationaleStatement": "AWS RDS will occasionally deprecate minor engine versions and provide new ones for upgrades. When the last version number within a release is replaced, the changed version is considered minor. With the Auto Minor Version Upgrade feature enabled, version upgrades will occur automatically during the specified maintenance window, allowing your RDS instances to receive new features, bug fixes, and security patches for their database engines.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** 1. Log in to the AWS management console and navigate to the RDS dashboard at https://console.aws.amazon.com/rds/. 2. In the left navigation panel, click `Databases`. 3. Select the RDS instance that you want to update. 4. Click on the `Modify` button located at the top right side. 5. On the `Modify DB Instance: ` page, In the `Maintenance` section, select `Auto minor version upgrade` and click the `Yes` radio button. 6. At the bottom of the page, click `Continue`, and check `Apply Immediately` to apply the changes immediately, or select `Apply during the next scheduled maintenance window` to avoid any downtime. 7. Review the changes and click `Modify DB Instance`. The instance status should change from available to modifying and back to available. Once the feature is enabled, the `Auto Minor Version Upgrade` status should change to `Yes`. **From Command Line:** 1. Run the `describe-db-instances` command to list all RDS database instance names available in the selected AWS region: ``` aws rds describe-db-instances --region --query 'DBInstances[*].DBInstanceIdentifier' ``` 2. The command output should return each database instance identifier. 3. Run the `modify-db-instance` command to modify the configuration of a selected RDS instance. This command will apply the changes immediately. Remove `--apply-immediately` to apply changes during the next scheduled maintenance window and avoid any downtime: ``` aws rds modify-db-instance --region --db-instance-identifier --auto-minor-version-upgrade --apply-immediately ``` 4. The command output should reveal the new configuration metadata for the RDS instance, including the `AutoMinorVersionUpgrade` parameter value. 5. Run the `describe-db-instances` command to check if the Auto Minor Version Upgrade feature has been successfully enabled: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].AutoMinorVersionUpgrade' ``` 6. The command output should return the feature's current status set to `true`, indicating that the feature is `enabled`, and that the minor engine upgrades will be applied to the selected RDS instance.", + "AuditProcedure": "**From Console:** 1. Log in to the AWS management console and navigate to the RDS dashboard at https://console.aws.amazon.com/rds/. 2. In the left navigation panel, click `Databases`. 3. Select the RDS instance that you want to examine. 4. Click on the `Maintenance and backups` panel. 5. Under the `Maintenance` section, search for the Auto Minor Version Upgrade status. - If the current status is `Disabled`, it means that the feature is not enabled, and the minor engine upgrades released will not be applied to the selected RDS instance. **From Command Line:** 1. Run the `describe-db-instances` command to list all RDS database names available in the selected AWS region: ``` aws rds describe-db-instances --region --query 'DBInstances[*].DBInstanceIdentifier' ``` 2. The command output should return each database instance identifier. 3. Run the `describe-db-instances` command again using a RDS instance identifier returned earlier to determine the Auto Minor Version Upgrade status for the selected instance: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].AutoMinorVersionUpgrade' ``` 4. The command output should return the current status of the feature. If the current status is set to `true`, the feature is enabled and the minor engine upgrades will be applied to the selected RDS instance.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_RDS_Managing.html:https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_UpgradeDBInstance.Upgrading.html:https://aws.amazon.com/rds/faqs/", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.2.3", + "Description": "Ensure that RDS instances are not publicly accessible", + "Checks": [ + "rds_instance_no_public_access" + ], + "Attributes": [ + { + "Section": "3 Storage", + "SubSection": "3.2 Relational Database Service (RDS)", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure and verify that the RDS database instances provisioned in your AWS account restrict unauthorized access in order to minimize security risks. To restrict access to any RDS database instance, you must disable the Publicly Accessible flag for the database and update the VPC security group associated with the instance.", + "RationaleStatement": "Ensure that no public-facing RDS database instances are provisioned in your AWS account, and restrict unauthorized access in order to minimize security risks. When the RDS instance allows unrestricted access (0.0.0.0/0), anyone and anything on the Internet can establish a connection to your database, which can increase the opportunity for malicious activities such as brute force attacks, PostgreSQL injections, or DoS/DDoS attacks.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** 1. Log in to the AWS management console and navigate to the RDS dashboard at https://console.aws.amazon.com/rds/. 2. Under the navigation panel, on the RDS dashboard, click `Databases`. 3. Select the RDS instance that you want to update. 4. Click `Modify` from the dashboard top menu. 5. On the Modify DB Instance panel, under the `Connectivity` section, click on `Additional connectivity configuration` and update the value for `Publicly Accessible` to `Not publicly accessible` to restrict public access. 6. Follow the below steps to update subnet configurations: - Select the `Connectivity and security` tab, and click the VPC attribute value inside the `Networking` section. - Select the `Details` tab from the VPC dashboard's bottom panel and click the Route table configuration attribute value. - On the Route table details page, select the Routes tab from the dashboard's bottom panel and click `Edit routes`. - On the Edit routes page, update the Destination of Target which is set to `igw-xxxxx` and click `Save` routes. 7. On the Modify DB Instance panel, click `Continue`, and in the Scheduling of modifications section, perform one of the following actions based on your requirements: - Select `Apply during the next scheduled maintenance window` to apply the changes automatically during the next scheduled maintenance window. - Select `Apply immediately` to apply the changes right away. With this option, any pending modifications will be asynchronously applied as soon as possible, regardless of the maintenance window setting for this RDS database instance. Note that any changes available in the pending modifications queue are also applied. If any of the pending modifications require downtime, choosing this option can cause unexpected downtime for the application. 8. Repeat steps 3-7 for each RDS instance in the current region. 9. Change the AWS region from the navigation bar to repeat the process for other regions. **From Command Line:** 1. Run the `describe-db-instances` command to list all available RDS database identifiers in the selected AWS region: ``` aws rds describe-db-instances --region --query 'DBInstances[*].DBInstanceIdentifier' ``` 2. The command output should return each database instance identifier. 3. Run the `modify-db-instance` command to modify the configuration of a selected RDS instance, disabling the `Publicly Accessible` flag for that instance. This command uses the `apply-immediately` flag. If you want to avoid any downtime, the `--no-apply-immediately` flag can be used: ``` aws rds modify-db-instance --region --db-instance-identifier --no-publicly-accessible --apply-immediately ``` 4. The command output should reveal the `PubliclyAccessible` configuration under pending values, to be applied at the specified time. 5. Updating the Internet Gateway destination via the AWS CLI is not currently supported. To update information about the Internet Gateway, please use the AWS Console procedure. 6. Repeat steps 1-5 for each RDS instance provisioned in the current region. 7. Change the AWS region by using the --region filter to repeat the process for other regions.", + "AuditProcedure": "**From Console:** 1. Log in to the AWS management console and navigate to the RDS dashboard at https://console.aws.amazon.com/rds/. 2. Under the navigation panel, on the RDS dashboard, click `Databases`. 3. Select the RDS instance that you want to examine. 4. Click `Instance Name` from the dashboard, under `Connectivity and Security`. 5. In the `Security` section, check if the Publicly Accessible flag status is set to `Yes`. 6. Follow the steps below to check database subnet access: - In the `networking` section, click the subnet link under `Subnets`. - The link will redirect you to the VPC Subnets page. - Select the subnet listed on the page and click the `Route Table` tab from the dashboard bottom panel. - If the route table contains any entries with the destination CIDR block set to `0.0.0.0/0` and an `Internet Gateway` attached, the selected RDS database instance was provisioned inside a public subnet; therefore, it is not running within a logically isolated environment and can be accessed from the Internet. 7. Repeat steps 3-6 to determine the configuration of other RDS database instances provisioned in the current region. 8. Change the AWS region from the navigation bar and repeat the audit process for other regions. **From Command Line:** 1. Run the `describe-db-instances` command to list all available RDS database names in the selected AWS region: ``` aws rds describe-db-instances --region --query 'DBInstances[*].DBInstanceIdentifier' ``` 2. The command output should return each database instance `identifier`. 3. Run the `describe-db-instances` command again, using the `PubliclyAccessible` parameter as a query filter to reveal the status of the database instance's Publicly Accessible flag: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].PubliclyAccessible' ``` 4. Check the Publicly Accessible parameter status. If the Publicly Accessible flag is set to `Yes`, then the selected RDS database instance is publicly accessible and insecure. Follow the steps mentioned below to check database subnet access. 5. Run the `describe-db-instances` command again using the RDS database instance identifier that you want to check, along with the appropriate filtering to describe the VPC subnet(s) associated with the selected instance: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].DBSubnetGroup.Subnets[]' ``` - The command output should list the subnets available in the selected database subnet group. 6. Run the `describe-route-tables` command using the ID of the subnet returned in the previous step to describe the routes of the VPC route table associated with the selected subnet: ``` aws ec2 describe-route-tables --region --filters Name=association.subnet-id,Values= --query 'RouteTables[*].Routes[]' ``` - If the command returns the route table associated with the database instance subnet ID, check the values of the `GatewayId` and `DestinationCidrBlock` attributes returned in the output. If the route table contains any entries with the `GatewayId` value set to `igw-xxxxxxxx` and the `DestinationCidrBlock` value set to `0.0.0.0/0`, the selected RDS database instance was provisioned within a public subnet. - Or, if the command returns empty results, the route table is implicitly associated with the subnet; therefore, the audit process continues with the next step. 7. Run the `describe-db-instances` command again using the RDS database instance identifier that you want to check, along with the appropriate filtering to describe the VPC ID associated with the selected instance: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].DBSubnetGroup.VpcId' ``` - The command output should show the VPC ID in the selected database subnet group. 8. Now run the `describe-route-tables` command using the ID of the VPC returned in the previous step to describe the routes of the VPC's main route table that is implicitly associated with the selected subnet: ``` aws ec2 describe-route-tables --region --filters Name=vpc-id,Values= Name=association.main,Values=true --query 'RouteTables[*].Routes[]' ``` - The command output returns the VPC main route table implicitly associated with the database instance subnet ID. Check the values of the `GatewayId` and `DestinationCidrBlock` attributes returned in the output. If the route table contains any entries with the `GatewayId` value set to `igw-xxxxxxxx` and the `DestinationCidrBlock` value set to `0.0.0.0/0`, the selected RDS database instance was provisioned inside a public subnet; therefore, it is not running within a logically isolated environment and does not adhere to AWS security best practices.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.html:https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Scenario2.html:https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_VPC.WorkingWithRDSInstanceinaVPC.html:https://aws.amazon.com/rds/faqs/", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.2.4", + "Description": "Ensure Multi-AZ deployments are used for enhanced availability in Amazon RDS", + "Checks": [ + "rds_cluster_multi_az", + "rds_instance_multi_az" + ], + "Attributes": [ + { + "Section": "3 Storage", + "SubSection": "3.2 Relational Database Service (RDS)", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Amazon RDS offers Multi-AZ deployments that provide enhanced availability and durability for your databases, using synchronous replication to replicate data to a standby instance in a different Availability Zone (AZ). In the event of an infrastructure failure, Amazon RDS automatically fails over to the standby to minimize downtime and ensure business continuity.", + "RationaleStatement": "Database availability is crucial for maintaining service uptime, particularly for applications that are critical to the business. Implementing Multi-AZ deployments with Amazon RDS ensures that your databases are protected against unplanned outages due to hardware failures, network issues, or other disruptions. This configuration enhances both the availability and durability of your database, making it a highly recommended practice for production environments.", + "ImpactStatement": "Multi-AZ deployments may increase costs due to the additional resources required to maintain a standby instance; however, the benefits of increased availability and reduced risk of downtime outweigh these costs for critical applications.", + "RemediationProcedure": "**From Console:** 1. Login to the AWS Management Console and open the RDS dashboard at [AWS RDS Console](https://console.aws.amazon.com/rds/). 2. In the left navigation pane, click on `Databases`. 3. Select the database instance that needs Multi-AZ deployment to be enabled. 4. Click the `Modify` button at the top right. 5. Scroll down to the `Availability & Durability` section. 6. Under `Multi-AZ deployment`, select `Yes` to enable. 7. Review the changes and click `Continue`. 8. On the `Review` page, choose `Apply immediately` to make the change without waiting for the next maintenance window, or `Apply during the next scheduled maintenance window`. 9. Click `Modify DB Instance` to apply the changes. **From Command Line:** 1. Run the following command to modify the RDS instance and enable Multi-AZ: ``` aws rds modify-db-instance --region --db-instance-identifier --multi-az --apply-immediately ``` 2. Confirm that the Multi-AZ deployment is enabled by running the following command: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].MultiAZ' ``` - The output should return `True`, indicating that Multi-AZ is enabled. 3. Repeat the procedure for other instances as necessary.", + "AuditProcedure": "**From Console:** 1. Login to the AWS Management Console and open the RDS dashboard at [AWS RDS Console](https://console.aws.amazon.com/rds/). 2. In the navigation pane, under `Databases`, select the RDS instance you want to examine. 3. Click the `Instance Name` to see details, then navigate to the `Configuration` tab. 4. Under the `Availability & Durability` section, check the `Multi-AZ` status. - If Multi-AZ deployment is enabled, it will display `Yes`. - If it is disabled, the status will display `No`. 5. Repeat steps 2-4 to verify the Multi-AZ status of other RDS instances in the same region. 6. Change the region from the top of the navigation bar and repeat the audit for other regions. **From Command Line:** 1. Run the following command to list all RDS instances in the selected AWS region: ``` aws rds describe-db-instances --region --query 'DBInstances[*].DBInstanceIdentifier' ``` 2. Run the following command using the instance identifier returned earlier to check the Multi-AZ status: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].MultiAZ' ``` - If the output is `True`, Multi-AZ is enabled. - If the output is `False`, Multi-AZ is not enabled. 3. Repeat steps 1 and 2 to audit each RDS instance, and change regions to verify in other regions.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.3.1", + "Description": "Ensure that encryption is enabled for EFS file systems", + "Checks": [ + "efs_encryption_at_rest_enabled" + ], + "Attributes": [ + { + "Section": "3 Storage", + "SubSection": "3.3 Elastic File System (EFS)", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "EFS data should be encrypted at rest using AWS KMS (Key Management Service).", + "RationaleStatement": "Data should be encrypted at rest to reduce the risk of a data breach via direct access to the storage device.", + "ImpactStatement": "", + "RemediationProcedure": "**It is important to note that EFS file system data-at-rest encryption must be turned on when creating the file system. If an EFS file system has been created without data-at-rest encryption enabled, then you must create another EFS file system with the correct configuration and transfer the data.** **Steps to create an EFS file system with data encrypted at rest:** **From Console:** 1. Login to the AWS Management Console and Navigate to the `Elastic File System (EFS)` dashboard. 2. Select `File Systems` from the left navigation panel. 3. Click the `Create File System` button from the dashboard top menu to start the file system setup process. 4. On the `Configure file system access` configuration page, perform the following actions: - Choose an appropriate VPC from the VPC dropdown list. - Within the `Create mount targets` section, check the boxes for all of the Availability Zones (AZs) within the selected VPC. These will be your mount targets. - Click `Next step` to continue. 5. Perform the following on the `Configure optional settings` page: - Create `tags` to describe your new file system. - Choose `performance mode` based on your requirements. - Check the `Enable encryption` box and choose `aws/elasticfilesystem` from the `Select KMS master key` dropdown list to enable encryption for the new file system, using the default master key provided and managed by AWS KMS. - Click `Next step` to continue. 6. Review the file system configuration details on the `review and create` page and then click `Create File System` to create your new AWS EFS file system. 7. Copy the data from the old unencrypted EFS file system onto the newly created encrypted file system. 8. Remove the unencrypted file system as soon as your data migration to the newly created encrypted file system is completed. 9. Change the AWS region from the navigation bar and repeat the entire process for the other AWS regions. **From CLI:** 1. Run the `describe-file-systems` command to view the configuration information for the selected unencrypted file system identified in the Audit steps: ``` aws efs describe-file-systems --region --file-system-id ``` 2. The command output should return the configuration information. 3. To provision a new AWS EFS file system, you need to generate a universally unique identifier (UUID) to create the token required by the `create-file-system` command. To create the required token, you can use a randomly generated UUID from https://www.uuidgenerator.net. 4. Run the `create-file-system` command using the unique token created at the previous step: ``` aws efs create-file-system --region --creation-token --performance-mode generalPurpose --encrypted ``` 5. The command output should return the new file system configuration metadata. 6. Run the `create-mount-target` command using the EFS file system ID returned from step 4 as the identifier and the ID of the Availability Zone (AZ) that will represent the mount target: ``` aws efs create-mount-target --region --file-system-id --subnet-id ``` 7. The command output should return the new mount target metadata. 8. Now you can mount your file system from an EC2 instance. 9. Copy the data from the old unencrypted EFS file system to the newly created encrypted file system. 10. Remove the unencrypted file system as soon as your data migration to the newly created encrypted file system is completed: ``` aws efs delete-file-system --region --file-system-id ``` 11. Change the AWS region by updating the --region and repeat the entire process for the other AWS regions.", + "AuditProcedure": "**From Console:** 1. Login to the AWS Management Console and Navigate to the Elastic File System (EFS) dashboard. 2. Select `File Systems` from the left navigation panel. 3. Each item on the list has a visible Encrypted field that displays data at rest encryption status. 4. Validate that this field reads `Encrypted` for all EFS file systems in all AWS regions. **From CLI:** 1. Run the `describe-file-systems` command using custom query filters to list the identifiers of all AWS EFS file systems currently available within the selected region: ``` aws efs describe-file-systems --region --output table --query 'FileSystems[*].FileSystemId' ``` 2. The command output should return a table with the requested file system IDs. 3. Run the `describe-file-systems` command using the ID of the file system that you want to examine as `file-system-id` and the necessary query filters: ``` aws efs describe-file-systems --region --file-system-id --query 'FileSystems[*].Encrypted' ``` 4. The command output should return the file system encryption status as `true` or `false`. If the returned value is `false`, the selected AWS EFS file system is not encrypted and if the returned value is `true`, the selected AWS EFS file system is encrypted.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/efs/latest/ug/encryption-at-rest.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/efs/index.html#efs", + "DefaultValue": "EFS file system data is encrypted at rest by default when creating a file system through the Console. However, encryption at rest is not enabled by default when creating a new file system using the AWS CLI, API, or SDKs." + } + ] + }, + { + "Id": "4.1", + "Description": "Ensure CloudTrail is enabled in all regions", + "Checks": [ + "cloudtrail_multi_region_enabled" + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "AWS CloudTrail is a web service that records AWS API calls for your account and delivers log files to you. The recorded information includes the identity of the API caller, the time of the API call, the source IP address of the API caller, the request parameters, and the response elements returned by the AWS service. CloudTrail provides a history of AWS API calls for an account, including API calls made via the Management Console, SDKs, command line tools, and higher-level AWS services (such as CloudFormation).", + "RationaleStatement": "The AWS API call history produced by CloudTrail enables security analysis, resource change tracking, and compliance auditing. Additionally, - ensuring that a multi-region trail exists will help detect unexpected activity occurring in otherwise unused regions - ensuring that a multi-region trail exists will ensure that `Global Service Logging` is enabled for a trail by default to capture recordings of events generated on AWS global services - for a multi-region trail, ensuring that management events are configured for all types of Read/Writes ensures the recording of management operations that are performed on all resources in an AWS account", + "ImpactStatement": "S3 lifecycle features can be used to manage the accumulation and management of logs over time. See the following AWS resource for more information on these features: 1. https://docs.aws.amazon.com/AmazonS3/latest/dev/object-lifecycle-mgmt.html", + "RemediationProcedure": "Perform the following to enable global (Multi-region) CloudTrail logging: **From Console:** 1. Sign in to the AWS Management Console and open the IAM console at [https://console.aws.amazon.com/cloudtrail](https://console.aws.amazon.com/cloudtrail). 2. Click on `Trails` in the left navigation pane. 3. Click `Get Started Now` if it is presented, then: - Click `Add new trail`. - Enter a trail name in the `Trail name` box. - A trail created in the console is a multi-region trail by default. - Specify an S3 bucket name in the `S3 bucket` box. - Specify the AWS KMS alias under the `Log file SSE-KMS encryption` section, or create a new key. - Click `Next`. 4. Ensure the `Management events` check box is selected. 5. Ensure both `Read` and `Write` are checked under API activity. 6. Click `Next`. 7. Review your trail settings and click `Create trail`. **From Command Line:** Create a multi-region trail: ``` aws cloudtrail create-trail --name --bucket-name --is-multi-region-trail ``` Enable multi-region on an existing trail: ``` aws cloudtrail update-trail --name --is-multi-region-trail ``` **Note:** Creating a CloudTrail trail via the CLI without providing any overriding options configures all `read` and `write` `Management Events` to be logged by default.", + "AuditProcedure": "Perform the following to determine if CloudTrail is enabled for all regions: **From Console:** 1. Sign in to the AWS Management Console and open the CloudTrail console at [https://console.aws.amazon.com/cloudtrail](https://console.aws.amazon.com/cloudtrail) 2. Click on `Trails` in the left navigation pane - You will be presented with a list of trails across all regions 3. Ensure that at least one Trail has `Yes` specified in the `Multi-region trail` column 4. Click on a trail via the link in the `Name` column 5. Ensure `Logging` is set to `ON` 6. Ensure `Multi-region trail` is set to `Yes` 7. In the section `Management Events`, ensure that `API activity` set to `ALL` **From Command Line:** 1. List all trails: ``` aws cloudtrail describe-trails ``` 2. Ensure `IsMultiRegionTrail` is set to `true`: ``` aws cloudtrail get-trail-status --name ``` 3. Ensure `IsLogging` is set to `true`: ``` aws cloudtrail get-event-selectors --trail-name ``` 4. Ensure there is at least one `fieldSelector` for a trail that equals `Management`: - This should NOT output any results for Field: readOnly. If either `true` or `false` is returned, one of the checkboxes (`read` or `write`) is not selected. Example of correct output: ``` TrailARN: , AdvancedEventSelectors: [ { Name: Management events selector, FieldSelectors: [ { Field: eventCategory, Equals: [ Management ] ```", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-concepts.html#cloudtrail-concepts-management-events:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/logging-management-and-data-events-with-cloudtrail.html?icmpid=docs_cloudtrail_console#logging-management-events:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-supported-services.html#cloud-trail-supported-services-data-events", + "DefaultValue": "Not Enabled" + } + ] + }, + { + "Id": "4.2", + "Description": "Ensure CloudTrail log file validation is enabled", + "Checks": [ + "cloudtrail_log_file_validation_enabled" + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "CloudTrail log file validation creates a digitally signed digest file containing a hash of each log that CloudTrail writes to S3. These digest files can be used to determine whether a log file was changed, deleted, or remained unchanged after CloudTrail delivered the log. It is recommended that file validation be enabled for all CloudTrails.", + "RationaleStatement": "Enabling log file validation will provide additional integrity checks for CloudTrail logs.", + "ImpactStatement": "", + "RemediationProcedure": "Perform the following to enable log file validation on a given trail: **From Console:** 1. Sign in to the AWS Management Console and open the IAM console at [https://console.aws.amazon.com/cloudtrail](https://console.aws.amazon.com/cloudtrail). 2. Click on `Trails` in the left navigation pane. 3. Click on the target trail. 4. Within the `General details` section, click `edit`. 5. Under `Advanced settings`, check the `enable` box under `Log file validation`. 6. Click `Save changes`. **From Command Line:** Enable log file validation on a trail: ``` aws cloudtrail update-trail --name --enable-log-file-validation ``` Note that periodic validation of logs using these digests can be carried out by running the following command: ``` aws cloudtrail validate-logs --trail-arn --start-time --end-time ```", + "AuditProcedure": "Perform the following on each trail to determine if log file validation is enabled: **From Console:** 1. Sign in to the AWS Management Console and open the IAM console at [https://console.aws.amazon.com/cloudtrail](https://console.aws.amazon.com/cloudtrail). 2. Click on `Trails` in the left navigation pane. 3. For every trail: - Click on a trail via the link in the `Name` column. - Under the `General details` section, ensure `Log file validation` is set to `Enabled`. **From Command Line:** List all trails: ``` aws cloudtrail describe-trails ``` Ensure `LogFileValidationEnabled` is set to `true` for each trail.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-log-file-validation-enabling.html", + "DefaultValue": "Not Enabled" + } + ] + }, + { + "Id": "4.3", + "Description": "Ensure AWS Config is enabled in all regions", + "Checks": [ + "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "AWS Config is a web service that performs configuration management of supported AWS resources within your account and delivers log files to you. The recorded information includes the configuration items (AWS resources), relationships between configuration items (AWS resources), and any configuration changes between resources. It is recommended that AWS Config be enabled in all regions.", + "RationaleStatement": "The AWS configuration item history captured by AWS Config enables security analysis, resource change tracking, and compliance auditing.", + "ImpactStatement": "Enabling AWS Config in all regions provides comprehensive visibility into resource configurations, enhancing security and compliance monitoring. However, this may incur additional costs and require proper configuration management.", + "RemediationProcedure": "To implement AWS Config configuration: **From Console:** 1. Select the region you want to focus on in the top right of the console. 2. Click `Services`. 3. Click `Config`. 4. If a Config Recorder is enabled in this region, navigate to the Settings page from the navigation menu on the left-hand side. If a Config Recorder is not yet enabled in this region, select Get Started. 5. Select Record all resources supported in this region. 6. Choose to include global resources (IAM resources). 7. Specify an S3 bucket in the same account or in another managed AWS account. 8. Create an SNS Topic from the same AWS account or another managed AWS account. **From Command Line:** 1. Ensure there is an appropriate S3 bucket, SNS topic, and IAM role per the [AWS Config Service prerequisites](http://docs.aws.amazon.com/config/latest/developerguide/gs-cli-prereq.html). 2. Run this command to create a new configuration recorder: ``` aws configservice put-configuration-recorder --configuration-recorder name=,roleARN=arn:aws:iam:::role/ --recording-group allSupported=true,includeGlobalResourceTypes=true ``` 3. Create a delivery channel configuration file locally which specifies the channel attributes, populated from the prerequisites set up previously: ``` { name: , s3BucketName: , snsTopicARN: arn:aws:sns:::, configSnapshotDeliveryProperties: { deliveryFrequency: Twelve_Hours } } ``` 4. Run this command to create a new delivery channel, referencing the json configuration file made in the previous step: ``` aws configservice put-delivery-channel --delivery-channel file://.json ``` 5. Start the configuration recorder by running the following command: ``` aws configservice start-configuration-recorder --configuration-recorder-name ```", + "AuditProcedure": "Process to evaluate AWS Config configuration per region: **From Console:** 1. Sign in to the AWS Management Console and open the AWS Config console at [https://console.aws.amazon.com/config/](https://console.aws.amazon.com/config/). 1. On the top right of the console select the target region. 1. If a Config Recorder is enabled in this region, you should navigate to the Settings page from the navigation menu on the left-hand side. If a Config Recorder is not yet enabled in this region, proceed to the remediation steps. 1. Ensure Record all resources supported in this region is checked. 1. Ensure Include global resources (e.g., AWS IAM resources) is checked, unless it is enabled in another region (this is only required in one region). 1. Ensure the correct S3 bucket has been defined. 1. Ensure the correct SNS topic has been defined. 1. Repeat steps 2 to 7 for each region. **From Command Line:** 1. Run this command to show all AWS Config Recorders and their properties: ``` aws configservice describe-configuration-recorders ``` 2. Evaluate the output to ensure that all recorders have a `recordingGroup` object which includes `allSupported: true`. Additionally, ensure that at least one recorder has `includeGlobalResourceTypes: true`. **Note:** There is one more parameter, ResourceTypes, in the recordingGroup object. We don't need to check it, as whenever we set allSupported to true, AWS enforces the resource types to be empty (ResourceTypes: []). Sample output: ``` { ConfigurationRecorders: [ { recordingGroup: { allSupported: true, resourceTypes: [], includeGlobalResourceTypes: true }, roleARN: arn:aws:iam:::role/service-role/, name: default } ] } ``` 3. Run this command to show the status for all AWS Config Recorders: ``` aws configservice describe-configuration-recorder-status ``` 4. In the output, find recorders with `name` key matching the recorders that were evaluated in step 2. Ensure that they include `recording: true` and `lastStatus: SUCCESS`.", + "AdditionalInformation": "", + "References": "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/configservice/describe-configuration-recorder-status.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/configservice/describe-configuration-recorders.html:https://docs.aws.amazon.com/config/latest/developerguide/gs-cli-prereq.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.4", + "Description": "Ensure that server access logging is enabled on the CloudTrail S3 bucket", + "Checks": [ + "cloudtrail_logs_s3_bucket_access_logging_enabled" + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Server access logging generates a log that contains access records for each request made to your S3 bucket. An access log record contains details about the request, such as the request type, the resources specified in the request worked, and the time and date the request was processed. It is recommended that server access logging be enabled on the CloudTrail S3 bucket.", + "RationaleStatement": "By enabling server access logging on target S3 buckets, it is possible to capture all events that may affect objects within any target bucket. Configuring the logs to be placed in a separate bucket allows access to log information that can be useful in security and incident response workflows.", + "ImpactStatement": "", + "RemediationProcedure": "Perform the following to enable server access logging: **From Console:** 1. Sign in to the AWS Management Console and open the S3 console at [https://console.aws.amazon.com/s3](https://console.aws.amazon.com/s3). 2. Under `All Buckets` click on the target S3 bucket. 3. Click on `Properties` in the top right of the console. 4. Under `Bucket: `, click `Logging`. 5. Configure bucket logging: - Check the `Enabled` box. - Select a Target Bucket from the list. - Enter a Target Prefix. 6. Click `Save`. **From Command Line:** 1. Get the name of the S3 bucket that CloudTrail is logging to: ``` aws cloudtrail describe-trails --region --query trailList[*].S3BucketName ``` 2. Copy and add the target bucket name at ``, the prefix for the log file at ``, and optionally add an email address in the following template, then save it as `.json`: ``` { LoggingEnabled: { TargetBucket: , TargetPrefix: , TargetGrants: [ { Grantee: { Type: AmazonCustomerByEmail, EmailAddress: }, Permission: FULL_CONTROL } ] } } ``` 3. Run the `put-bucket-logging` command with bucket name and `.json` as input; for more information, refer to [put-bucket-logging](https://docs.aws.amazon.com/cli/latest/reference/s3api/put-bucket-logging.html): ``` aws s3api put-bucket-logging --bucket --bucket-logging-status file://.json ```", + "AuditProcedure": "Perform the following ensure that the CloudTrail S3 bucket has access logging is enabled: **From Console:** 1. Go to the Amazon CloudTrail console at [https://console.aws.amazon.com/cloudtrail/home](https://console.aws.amazon.com/cloudtrail/home). 2. In the API activity history pane on the left, click `Trails`. 3. In the Trails pane, note the bucket names in the S3 bucket column. 4. Sign in to the AWS Management Console and open the S3 console at [https://console.aws.amazon.com/s3](https://console.aws.amazon.com/s3). 5. Under `All Buckets` click on a target S3 bucket. 6. Click on `Properties` in the top right of the console. 7. Under `Bucket: `, click `Logging`. 8. Ensure `Enabled` is checked. **From Command Line:** 1. Get the name of the S3 bucket that CloudTrail is logging to: ``` aws cloudtrail describe-trails --query 'trailList[*].S3BucketName' ``` 2. Ensure logging is enabled on the bucket: ``` aws s3api get-bucket-logging --bucket ``` Ensure the command does not return an empty output. Sample output for a bucket with logging enabled: ``` { LoggingEnabled: { TargetPrefix: , TargetBucket: } } ```", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AmazonS3/latest/dev/ServerLogs.html:https://docs.aws.amazon.com/AmazonS3/latest/userguide/enable-server-access-logging.html", + "DefaultValue": "Logging is disabled." + } + ] + }, + { + "Id": "4.5", + "Description": "Ensure CloudTrail logs are encrypted at rest using KMS CMKs", + "Checks": [ + "cloudtrail_kms_encryption_enabled" + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "AWS CloudTrail is a web service that records AWS API calls for an account and makes those logs available to users and resources in accordance with IAM policies. AWS Key Management Service (KMS) is a managed service that helps create and control the encryption keys used to encrypt account data, and uses Hardware Security Modules (HSMs) to protect the security of encryption keys. CloudTrail logs can be configured to leverage server side encryption (SSE) and KMS customer-created master keys (CMK) to further protect CloudTrail logs. It is recommended that CloudTrail be configured to use SSE-KMS.", + "RationaleStatement": "Configuring CloudTrail to use SSE-KMS provides additional confidentiality controls on log data, as a given user must have S3 read permission on the corresponding log bucket and must be granted decrypt permission by the CMK policy.", + "ImpactStatement": "Customer-created keys incur an additional cost. See https://aws.amazon.com/kms/pricing/ for more information.", + "RemediationProcedure": "Perform the following to configure CloudTrail to use SSE-KMS: **From Console:** 1. Sign in to the AWS Management Console and open the CloudTrail console at [https://console.aws.amazon.com/cloudtrail](https://console.aws.amazon.com/cloudtrail). 2. In the left navigation pane, choose `Trails`. 3. Click on a trail. 4. Under the `S3` section, click the edit button (pencil icon). 5. Click `Advanced`. 6. Select an existing CMK from the `KMS key Id` drop-down menu. - **Note:** Ensure the CMK is located in the same region as the S3 bucket. - **Note:** You will need to apply a KMS key policy on the selected CMK in order for CloudTrail, as a service, to encrypt and decrypt log files using the CMK provided. View the AWS documentation for [editing the selected CMK Key policy](https://docs.aws.amazon.com/awscloudtrail/latest/userguide/create-kms-key-policy-for-cloudtrail.html). 7. Click `Save`. 8. You will see a notification message stating that you need to have decryption permissions on the specified KMS key to decrypt log files. 9. Click `Yes`. **From Command Line:** Run the following command to specify a KMS key ID to use with a trail: ``` aws cloudtrail update-trail --name --kms-key-id ``` Run the following command to attach a key policy to a specified KMS key: ``` aws kms put-key-policy --key-id --policy ```", + "AuditProcedure": "Perform the following to determine if CloudTrail is configured to use SSE-KMS: **From Console:** 1. Sign in to the AWS Management Console and open the CloudTrail console at [https://console.aws.amazon.com/cloudtrail](https://console.aws.amazon.com/cloudtrail). 2. In the left navigation pane, choose `Trails`. 3. Select a trail. 4. In the `General details` section, select `Edit` to edit the trail configuration. 5. Ensure the box at `Log file SSE-KMS encryption` is checked and that a valid `AWS KMS alias` of a KMS key is entered in the respective text box. **From Command Line:** 1. Run the following command: ``` aws cloudtrail describe-trails ``` 2. For each trail listed, SSE-KMS is enabled if the trail has a `KmsKeyId` property defined.", + "AdditionalInformation": "Three statements that need to be added to the CMK policy: 1. Enable CloudTrail to describe CMK properties: ``` { \"Sid\": \"Allow CloudTrail access\", \"Effect\": \"Allow\", \"Principal\": { \"Service\": \"cloudtrail.amazonaws.com\" }, \"Action\": \"kms:DescribeKey\", \"Resource\": \"*\" } ``` 2. Granting encrypt permissions: ``` { \"Sid\": \"Allow CloudTrail to encrypt logs\", \"Effect\": \"Allow\", \"Principal\": { \"Service\": \"cloudtrail.amazonaws.com\" }, \"Action\": \"kms:GenerateDataKey*\", \"Resource\": \"*\", \"Condition\": { \"StringLike\": { \"kms:EncryptionContext:aws:cloudtrail:arn\": [ \"arn:aws:cloudtrail:*:aws-account-id:trail/*\" ] } } } ``` 3. Granting decrypt permissions: ``` { \"Sid\": \"Enable CloudTrail log decrypt permissions\", \"Effect\": \"Allow\", \"Principal\": { \"AWS\": \"arn:aws:iam::aws-account-id:user/username\" }, \"Action\": \"kms:Decrypt\", \"Resource\": \"*\", \"Condition\": { \"Null\": { \"kms:EncryptionContext:aws:cloudtrail:arn\": \"false\" } } } ```", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/encrypting-cloudtrail-log-files-with-aws-kms.html:https://docs.aws.amazon.com/kms/latest/developerguide/create-keys.html:CCE-78919-8:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/cloudtrail/update-trail.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/kms/put-key-policy.html", + "DefaultValue": "By default, CloudTrail logs are not encrypted with a KMS CMK. Logs may be encrypted with SSE-S3, but this does not provide the same level of control or auditing as KMS CMKs." + } + ] + }, + { + "Id": "4.6", + "Description": "Ensure rotation for customer-created symmetric CMKs is enabled", + "Checks": [ + "kms_cmk_rotation_enabled" + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "AWS Key Management Service (KMS) allows customers to rotate the backing key, which is key material stored within the KMS that is tied to the key ID of the customer-created customer master key (CMK). The backing key is used to perform cryptographic operations such as encryption and decryption. Automated key rotation currently retains all prior backing keys so that decryption of encrypted data can occur transparently. It is recommended that CMK key rotation be enabled for symmetric keys. Key rotation cannot be enabled for any asymmetric CMK.", + "RationaleStatement": "Rotating encryption keys helps reduce the potential impact of a compromised key, as data encrypted with a new key cannot be accessed with a previous key that may have been exposed. Keys should be rotated every year or upon an event that could result in the compromise of that key.", + "ImpactStatement": "Creation, management, and storage of CMKs may require additional time from an administrator.", + "RemediationProcedure": "**From Console:** 1. Sign in to the AWS Management Console and open the KMS console at: [https://console.aws.amazon.com/kms](https://console.aws.amazon.com/kms). 2. In the left navigation pane, click `Customer-managed keys`. 3. Select a key with `Key spec = SYMMETRIC_DEFAULT` that does not have automatic rotation enabled. 4. Select the `Key rotation` tab. 5. Check the `Automatically rotate this KMS key every year` box. 6. Click `Save`. 7. Repeat steps 3–6 for all customer-managed CMKs that do not have automatic rotation enabled. **From Command Line:** 1. Run the following command to enable key rotation: ``` aws kms enable-key-rotation --key-id ```", + "AuditProcedure": "**From Console:** 1. Sign in to the AWS Management Console and open the KMS console at: [https://console.aws.amazon.com/kms](https://console.aws.amazon.com/kms). 2. In the left navigation pane, click `Customer-managed keys`. 3. Select a customer-managed CMK where `Key spec = SYMMETRIC_DEFAULT`. 4. Select the `Key rotation` tab. 5. Ensure the `Automatically rotate this KMS key every year` box is checked. 6. Repeat steps 3–5 for all customer-managed CMKs where `Key spec = SYMMETRIC_DEFAULT`. **From Command Line:** 1. Run the following command to get a list of all keys and their associated `KeyIds`: ``` aws kms list-keys ``` 2. For each key, note the KeyId and run the following command: ``` describe-key --key-id ``` 3. If the response contains `KeySpec = SYMMETRIC_DEFAULT`, run the following command: ``` aws kms get-key-rotation-status --key-id ``` 4. Ensure `KeyRotationEnabled` is set to `true`. 5. Repeat steps 2–4 for all remaining CMKs.", + "AdditionalInformation": "", + "References": "https://aws.amazon.com/kms/pricing/:https://csrc.nist.gov/publications/detail/sp/800-57-part-1/rev-5/final", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.7", + "Description": "Ensure VPC flow logging is enabled in all VPCs", + "Checks": [ + "vpc_flow_logs_enabled" + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "VPC Flow Logs is a feature that enables you to capture information about the IP traffic going to and from network interfaces in your VPC. After you've created a flow log, you can view and retrieve its data in Amazon CloudWatch Logs. It is recommended that VPC Flow Logs be enabled for packet Rejects for VPCs.", + "RationaleStatement": "VPC Flow Logs provide visibility into network traffic that traverses the VPC and can be used to detect anomalous traffic or gain insights during security workflows.", + "ImpactStatement": "By default, CloudWatch Logs will store logs indefinitely unless a specific retention period is defined for the log group. When choosing the number of days to retain, keep in mind that the average time it takes for an organization to realize they have been breached is 210 days (at the time of this writing). Since additional time is required to research a breach, a minimum retention policy of 365 days allows for detection and investigation. You may also wish to archive the logs to a cheaper storage service rather than simply deleting them. See the following AWS resource to manage CloudWatch Logs retention periods: 1. https://docs.aws.amazon.com/AmazonCloudWatch/latest/DeveloperGuide/SettingLogRetention.html", + "RemediationProcedure": "Perform the following to enable VPC Flow Logs: **From Console:** 1. Sign into the management console. 2. Select `Services`, then select `VPC`. 3. In the left navigation pane, select `Your VPCs`. 4. Select a VPC. 5. In the right pane, select the `Flow Logs` tab. 6. If no Flow Log exists, click `Create Flow Log`. 7. For Filter, select `Reject`. 8. Enter a `Role` and `Destination Log Group`. 9. Click `Create Log Flow`. 10. Click on `CloudWatch Logs Group`. **Note:** Setting the filter to Reject will dramatically reduce the accumulation of logging data for this recommendation and provide sufficient information for the purposes of breach detection, research, and remediation. However, during periods of least privilege security group engineering, setting the filter to All can be very helpful in discovering existing traffic flows required for the proper operation of an already running environment. **From Command Line:** 1. Create a policy document, name it `role_policy_document.json`, and paste the following content: ``` { Version: 2012-10-17, Statement: [ { Sid: test, Effect: Allow, Principal: { Service: ec2.amazonaws.com }, Action: sts:AssumeRole } ] } ``` 2. Create another policy document, name it `iam_policy.json`, and paste the following content: ``` { Version: 2012-10-17, Statement: [ { Effect: Allow, Action:[ logs:CreateLogGroup, logs:CreateLogStream, logs:DescribeLogGroups, logs:DescribeLogStreams, logs:PutLogEvents, logs:GetLogEvents, logs:FilterLogEvents ], Resource: * } ] } ``` 3. Run the following command to create an IAM role: ``` aws iam create-role --role-name --assume-role-policy-document file://role_policy_document.json ``` 4. Run the following command to create an IAM policy: ``` aws iam create-policy --policy-name --policy-document file://iam-policy.json ``` 5. Run the `attach-group-policy` command, using the IAM policy ARN returned from the previous step to attach the policy to the IAM role: ``` aws iam attach-group-policy --policy-arn arn:aws:iam:::policy/ --group-name ``` - If the command succeeds, no output is returned. 6. Run the `describe-vpcs` command to get a list of VPCs in the selected region: ``` aws ec2 describe-vpcs --region ``` - The command output should return a list of VPCs in the selected region. 7. Run the `create-flow-logs` command to create a flow log for a VPC: ``` aws ec2 create-flow-logs --resource-type VPC --resource-ids --traffic-type REJECT --log-group-name --deliver-logs-permission-arn ``` 8. Repeat step 7 for other VPCs in the selected region. 9. Change the region by updating --region, and repeat the remediation procedure for each region.", + "AuditProcedure": "Perform the following to determine if VPC Flow logs are enabled: **From Console:** 1. Sign into the management console. 2. Select `Services`, then select `VPC`. 3. In the left navigation pane, select `Your VPCs`. 4. Select a VPC. 5. In the right pane, select the `Flow Logs` tab. 6. Ensure a Log Flow exists that has `Active` in the `Status` column. **From Command Line:** 1. Run the `describe-vpcs` command (OSX/Linux/UNIX) to list the VPC networks available in the current AWS region: ``` aws ec2 describe-vpcs --region --query Vpcs[].VpcId ``` 2. The command output returns the `VpcId` of VPCs available in the selected region. 3. Run the `describe-flow-logs` command (OSX/Linux/UNIX) using the VPC ID to determine if the selected virtual network has the Flow Logs feature enabled: ``` aws ec2 describe-flow-logs --filter Name=resource-id,Values= ``` - If there are no Flow Logs created for the selected VPC, the command output will return an empty list `[]`. 4. Repeat step 3 for other VPCs in the same region. 5. Change the region by updating `--region`, and repeat steps 1-4 for each region.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AmazonVPC/latest/UserGuide/flow-logs.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.8", + "Description": "Ensure that object-level logging for write events is enabled for S3 buckets", + "Checks": [ + "cloudtrail_s3_dataevents_write_enabled" + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "S3 object-level API operations, such as GetObject, DeleteObject, and PutObject, are referred to as data events. By default, CloudTrail trails do not log data events, so it is recommended to enable object-level logging for S3 buckets.", + "RationaleStatement": "Enabling object-level logging will help you meet data compliance requirements within your organization, perform comprehensive security analyses, monitor specific patterns of user behavior in your AWS account, or take immediate actions on any object-level API activity within your S3 buckets using Amazon CloudWatch Events.", + "ImpactStatement": "Enabling logging for these object-level events may significantly increase the number of events logged and may incur additional costs.", + "RemediationProcedure": "**From Console:** 1. Login to the AWS Management Console and navigate to the S3 dashboard at `https://console.aws.amazon.com/s3/`. 2. In the left navigation panel, click `buckets`, and then click the name of the S3 bucket you want to examine. 3. Click the `Properties` tab to see the bucket configuration in detail. 4. In the `AWS CloudTrail data events` section, select the trail name for recording activity. You can choose an existing trail or create a new one by clicking the `Configure in CloudTrail` button or navigating to the [CloudTrail console](https://console.aws.amazon.com/cloudtrail/). 5. Once the trail is selected, select the `Data Events` check box. 6. Select `S3` from the `Data event type` drop-down. 7. Select `Log all events` from the `Log selector template` drop-down. 8. Repeat steps 2-7 to enable object-level logging of write events for other S3 buckets. **From Command Line:** 1. To enable `object-level` data events logging for S3 buckets within your AWS account, run the `put-event-selectors` command using the name of the trail that you want to reconfigure as identifier: ``` aws cloudtrail put-event-selectors --region --trail-name --event-selectors '[{ ReadWriteType: WriteOnly, IncludeManagementEvents:true, DataResources: [{ Type: AWS::S3::Object, Values: [arn:aws:s3:::/] }] }]' ``` 2. The command output will be `object-level` event trail configuration. 3. If you want to enable it for all buckets at once, change the Values parameter to `[arn:aws:s3]` in the previous command. 4. Repeat step 1 for each s3 bucket to update `object-level` logging of write events. 5. Change the AWS region by updating the `--region` command parameter, and perform the process for the other regions.", + "AuditProcedure": "**From Console:** 1. Login to the AWS Management Console and navigate to the CloudTrail dashboard at `https://console.aws.amazon.com/cloudtrail/`. 2. In the left panel, click `Trails`, and then click the name of the trail that you want to examine. 3. Review `General details`. 4. Confirm that `Multi-region trail` is set to `Yes`. 5. Scroll down to `Data events` and confirm the configuration: - If `advanced event selectors` is being used, it should read: ``` Data Events: S3 Log selector template Log all events ``` - If `basic event selectors` is being used, it should read: ``` Data events: S3 Bucket Name: All current and future S3 buckets Write: Enabled ``` 6. Repeat steps 2-5 to verify that each trail has multi-region enabled and is configured to log data events. If a trail does not have multi-region enabled and data event logging configured, refer to the remediation steps. **From Command Line:** 1. Run the `list-trails` command to list all trails: ``` aws cloudtrail list-trails ``` 2. The command output will be a list of trails: ``` TrailARN: arn:aws:cloudtrail:::trail/, Name: , HomeRegion: ``` 3. Run the `get-trail` command to determine whether a trail is a multi-region trail: ``` aws cloudtrail get-trail --name --region ``` 4. The command output should include: `IsMultiRegionTrail: true`. 5. Run the `get-event-selectors` command, using the `Name` of the trail and the `region` returned in step 2, to determine if data event logging is configured: ``` aws cloudtrail get-event-selectors --region --trail-name --query EventSelectors[*].DataResources[] ``` 6. The command output should be an array that includes the S3 bucket defined for data event logging: ``` Type: AWS::S3::Object, Values: [ arn:aws:s3 ``` 7. If the `get-event-selectors` command returns an empty array, data events are not included in the trail's logging configuration; therefore, object-level API operations performed on S3 buckets within your AWS account are not being recorded. 8. Repeat steps 1-7 to verify that each trail has multi-region enabled and is configured to log data events. If a trail does not have multi-region enabled and data event logging configured, refer to the remediation steps.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AmazonS3/latest/user-guide/enable-cloudtrail-events.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.9", + "Description": "Ensure that object-level logging for read events is enabled for S3 buckets", + "Checks": [ + "cloudtrail_s3_dataevents_read_enabled" + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "S3 object-level API operations, such as GetObject, DeleteObject, and PutObject, are referred to as data events. By default, CloudTrail trails do not log data events, so it is recommended to enable object-level logging for S3 buckets.", + "RationaleStatement": "Enabling object-level logging will help you meet data compliance requirements within your organization, perform comprehensive security analyses, monitor specific patterns of user behavior in your AWS account, or take immediate actions on any object-level API activity within your S3 buckets using Amazon CloudWatch Events.", + "ImpactStatement": "Enabling logging for these object-level events may significantly increase the number of events logged and may incur additional costs.", + "RemediationProcedure": "**From Console:** 1. Login to the AWS Management Console and navigate to S3 dashboard at `https://console.aws.amazon.com/s3/`. 2. In the left navigation panel, click `buckets` and then click the name of the S3 bucket that you want to examine. 3. Click the `Properties` tab to see the bucket configuration in detail. 4. In the `AWS Cloud Trail data events` section, select the trail name for recording activity. You can choose an existing trail or create a new one by clicking the `Configure in CloudTrail` button or navigating to the [CloudTrail console](https://console.aws.amazon.com/cloudtrail/). 5. Once the trail is selected, select the `Data Events` check box. 6. Select `S3` from the `Data event type` drop-down. 7. Select `Log all events` from the `Log selector template` drop-down. 8. Repeat steps 2-7 to enable object-level logging of read events for other S3 buckets. **From Command Line:** 1. To enable `object-level` data events logging for S3 buckets within your AWS account, run the `put-event-selectors` command using the name of the trail that you want to reconfigure as identifier: ``` aws cloudtrail put-event-selectors --region --trail-name --event-selectors '[{ ReadWriteType: ReadOnly, IncludeManagementEvents:true, DataResources: [{ Type: AWS::S3::Object, Values: [arn:aws:s3:::/] }] }]' ``` 2. The command output will be `object-level` event trail configuration. 3. If you want to enable it for all buckets at once, change the Values parameter to `[arn:aws:s3]` in the previous command. 4. Repeat step 1 for each s3 bucket to update `object-level` logging of read events. 5. Change the AWS region by updating the `--region` command parameter, and perform the process for the other regions.", + "AuditProcedure": "**From Console:** 1. Login to the AWS Management Console and navigate to the CloudTrail dashboard at `https://console.aws.amazon.com/cloudtrail/`. 2. In the left panel, click `Trails`, and then click the name of the trail that you want to examine. 3. Review `General details`. 4. Confirm that `Multi-region trail` is set to `Yes` 5. Scroll down to `Data events` 5. Scroll down to `Data events` and confirm the configuration: - If `advanced event selectors` is being used, it should read: ``` Data Events: S3 Log selector template Log all events ``` - If `basic event selectors` is being used, it should read: ``` Data events: S3 Bucket Name: All current and future S3 buckets Read: Enabled ``` 6. Repeat steps 2-5 to verify that each trail has multi-region enabled and is configured to log data events. If a trail does not have multi-region enabled and data event logging configured, refer to the remediation steps. **From Command Line:** 1. Run the `describe-trails` command to list all trail names: ``` aws cloudtrail describe-trails --region --output table --query trailList[*].Name ``` 2. The command output will be table of the trail names. 3. Run the `get-event-selectors` command using the name of a trail returned at the previous step and custom query filters to determine if data event logging is configured: ``` aws cloudtrail get-event-selectors --region --trail-name --query EventSelectors[*].DataResources[] ``` 4. The command output should be an array that includes the S3 bucket defined for data event logging. 5. If the `get-event-selectors` command returns an empty array, data events are not included in the trail's logging configuration; therefore, object-level API operations performed on S3 buckets within your AWS account are not being recorded. 6. Repeat steps 1-5 to verify the configuration of each trail. 7. Change the AWS region by updating the `--region` command parameter, and perform the audit process for other regions.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AmazonS3/latest/user-guide/enable-cloudtrail-events.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.10", + "Description": "Ensure all AWS-managed web front-end services have access logging enabled", + "Checks": [ + "cloudfront_distributions_logging_enabled", + "elbv2_logging_enabled", + "apigateway_restapi_logging_enabled", + "apigatewayv2_api_access_logging_enabled" + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that access logging is enabled for all AWS-managed web front-end services that terminate or front HTTP(S) traffic, including Amazon CloudFront distributions, Application Load Balancers (ALB), Network Load Balancers (NLB), and Amazon API Gateway REST/HTTP API stages with public endpoints. Access logs must be enabled with delivery to a designated S3 bucket or CloudWatch Logs destination that is protected with appropriate access controls. This control requires logging of request details such as client IP address, timestamp, HTTP method, requested URI, response status code, bytes transferred, and user agent for every request processed by these services. CloudTrail provides management event logging for these resources, but access logs are required to capture the actual HTTP request/response activity at the network edge layers.", + "RationaleStatement": "AWS-managed web front-end services (CloudFront, ALB/NLB, API Gateway) represent the primary HTTP(S) ingress points into AWS accounts and are the first line of defense against web attacks, reconnaissance, and abuse attempts. CloudTrail logs management actions (create/update/delete) and data events but does not capture the content of HTTP requests/responses or client activity, leaving a critical visibility gap for security monitoring and incident response. Access logs from these services enable reconstruction of all web traffic, detection of anomalous patterns, forensic analysis of incidents, and compliance proof that internet-facing entry points were monitored. Without these logs, security teams cannot distinguish legitimate traffic from attacks or prove access patterns during audits.", + "ImpactStatement": "Enabling access logging incurs additional storage costs for log delivery and retention, as well as minor configuration overhead for creating dedicated logging buckets, IAM roles, and retention policies. Costs can be managed through lifecycle policies, log sampling, and tiered storage classes.", + "RemediationProcedure": "Following instructions enable standard access logging for CloudFront distributions using the AWS Management Console. 1. Open the CloudFront console from the AWS Management Console. 2. Click Distributions in the left navigation and click on the Distribution ID needing remediation. 3. Go to the \"Logging\" tab and click on \"Create access log delivery\" - Select \"Deliver to\" for your preferred location: S3 or CloudWatch log group - Select the ARN of your log destination resource - Click on Submit 4. Confirm if you see the access log destination in the logging tab", + "AuditProcedure": "As an example with CloudFront, verify following the below steps if access logging is enabled: 1. Open the CloudFront console from the AWS Management Console. 2. Click Distributions in the left navigation. 3. For each Distribution ID (e.g., E123ABC...), click the Distribution ID and go to the \"Logging\" tab 4. Check if one or more \"Access log destinations\" are present with a destination type of S3 or CloudWatch log group.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1", + "Description": "Ensure unauthorized API calls are monitored", + "Checks": [ + "cloudwatch_log_metric_filter_unauthorized_api_calls" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be established for unauthorized API calls.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring unauthorized API calls will help reduce the time it takes to detect malicious activity and can alert you to potential security incidents.", + "ImpactStatement": "This alert may be triggered by normal read-only console activities that attempt to opportunistically gather optional information but gracefully fail if they lack the necessary permissions. If an excessive number of alerts are generated, then an organization may wish to consider adding read access to the limited IAM user permissions solely to reduce the number of alerts. In some cases, doing this may allow users to actually view some areas of the system; any additional access granted should be reviewed for alignment with the original limited IAM user intent.", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for unauthorized API calls and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=unauthorized_api_calls_metric,metricNamespace=CISBenchmark,metricValue=1 --filter-pattern { ($.errorCode =*UnauthorizedOperation) || ($.errorCode =AccessDenied*) && ($.sourceIPAddress!=delivery.logs.amazonaws.com) && ($.eventName!=HeadBucket) } ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name unauthorized_api_calls_alarm --metric-name unauthorized_api_calls_metric --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace CISBenchmark --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.errorCode =*UnauthorizedOperation) || ($.errorCode =AccessDenied*) && ($.sourceIPAddress!=delivery.logs.amazonaws.com) && ($.eventName!=HeadBucket) }, ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query MetricAlarms[?MetricName == ] ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://aws.amazon.com/sns/:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.2", + "Description": "Ensure management console sign-in without MFA is monitored", + "Checks": [ + "cloudwatch_log_metric_filter_sign_in_without_mfa" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be established for console logins that are not protected by multi-factor authentication (MFA).", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring for single-factor console logins will increase visibility into accounts that are not protected by MFA. These type of accounts are more susceptible to compromise and unauthorized access.", + "ImpactStatement": "", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for AWS Management Console sign-ins without MFA and uses the `` taken from audit step 1. ``` aws logs put-metric-filter --log-group-name --filter-name `` --metric-transformations metricName= ``,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventName = ConsoleLogin) && ($.additionalEventData.MFAUsed != Yes) }' ``` Or, to reduce false positives in case Single Sign-On (SSO) is used in the organization: ``` aws logs put-metric-filter --log-group-name --filter-name `` --metric-transformations metricName= ``,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventName = ConsoleLogin) && ($.additionalEventData.MFAUsed != Yes) && ($.userIdentity.type = IAMUser) && ($.responseElements.ConsoleLogin = Success) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name `` --metric-name `` --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: ``` aws cloudtrail describe-trails ``` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: ``` aws cloudtrail get-trail-status --name ``` - ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: ``` aws cloudtrail get-event-selectors --trail-name ``` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventName = ConsoleLogin) && ($.additionalEventData.MFAUsed != Yes) } ``` Or, to reduce false positives in case Single Sign-On (SSO) is used in the organization: ``` filterPattern: { ($.eventName = ConsoleLogin) && ($.additionalEventData.MFAUsed != Yes) && ($.userIdentity.type = IAMUser) && ($.responseElements.ConsoleLogin = Success) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4. ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName== ]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored Filter pattern set to `{ ($.eventName = ConsoleLogin) && ($.additionalEventData.MFAUsed != Yes) && ($.userIdentity.type = IAMUser) && ($.responseElements.ConsoleLogin = Success}`: - reduces false alarms raised when a user logs in via SSO", + "References": "https://docs.aws.amazon.com/AmazonCloudWatch/latest/DeveloperGuide/viewing_metrics_with_cloudwatch.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.3", + "Description": "Ensure usage of the 'root' account is monitored", + "Checks": [ + "cloudwatch_log_metric_filter_root_usage" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be established for 'root' login attempts to detect unauthorized use or attempts to use the root account.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring 'root' account logins will provide visibility into the use of a fully privileged account and the opportunity to reduce its usage.", + "ImpactStatement": "", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for 'root' account usage and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name `` --filter-name `` --metric-transformations metricName= `` ,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ $.userIdentity.type = Root && $.userIdentity.invokedBy NOT EXISTS && $.eventType != AwsServiceEvent }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name `` --metric-name `` --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: ``` aws cloudtrail describe-trails ``` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: ``` aws cloudtrail get-trail-status --name ``` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: ``` aws cloudtrail get-event-selectors --trail-name ``` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { $.userIdentity.type = Root && $.userIdentity.invokedBy NOT EXISTS && $.eventType != AwsServiceEvent } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.4", + "Description": "Ensure IAM policy changes are monitored", + "Checks": [ + "cloudwatch_log_metric_filter_policy_changes" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be established for changes made to Identity and Access Management (IAM) policies.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring changes to IAM policies will help ensure authentication and authorization controls remain intact.", + "ImpactStatement": "Monitoring these changes may result in a number of false positives, especially in larger environments. This alert may require more tuning than others to eliminate some of those erroneous notifications.", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for IAM policy changes and the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name `` --filter-name `` --metric-transformations metricName= ``,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{($.eventName=DeleteGroupPolicy)||($.eventName=DeleteRolePolicy)||($.eventName=DeleteUserPolicy)||($.eventName=PutGroupPolicy)||($.eventName=PutRolePolicy)||($.eventName=PutUserPolicy)||($.eventName=CreatePolicy)||($.eventName=DeletePolicy)||($.eventName=CreatePolicyVersion)||($.eventName=DeletePolicyVersion)||($.eventName=AttachRolePolicy)||($.eventName=DetachRolePolicy)||($.eventName=AttachUserPolicy)||($.eventName=DetachUserPolicy)||($.eventName=AttachGroupPolicy)||($.eventName=DetachGroupPolicy)}' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name `` --metric-name `` --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrails: ``` aws cloudtrail describe-trails ``` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: ``` aws cloudtrail get-trail-status --name ``` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: ``` aws cloudtrail get-event-selectors --trail-name ``` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: {($.eventName=DeleteGroupPolicy)||($.eventName=DeleteRolePolicy)||($.eventName=DeleteUserPolicy)||($.eventName=PutGroupPolicy)||($.eventName=PutRolePolicy)||($.eventName=PutUserPolicy)||($.eventName=CreatePolicy)||($.eventName=DeletePolicy)||($.eventName=CreatePolicyVersion)||($.eventName=DeletePolicyVersion)||($.eventName=AttachRolePolicy)||($.eventName=DetachRolePolicy)||($.eventName=AttachUserPolicy)||($.eventName=DetachUserPolicy)||($.eventName=AttachGroupPolicy)||($.eventName=DetachGroupPolicy)} ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.5", + "Description": "Ensure CloudTrail configuration changes are monitored", + "Checks": [ + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be used to detect changes to CloudTrail's configurations.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring changes to CloudTrail's configuration will help ensure sustained visibility into the activities performed in the AWS account.", + "ImpactStatement": "Ensuring that changes to CloudTrail configurations are monitored enhances security by maintaining the integrity of logging mechanisms. Automated monitoring can provide real-time alerts; however, it may require additional setup and resources to configure and manage these alerts effectively. These steps can be performed manually within a company's existing SIEM platform in cases where CloudTrail logs are monitored outside of the AWS monitoring tools in CloudWatch.", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for CloudTrail configuration changes and the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventName = CreateTrail) || ($.eventName = UpdateTrail) || ($.eventName = DeleteTrail) || ($.eventName = StartLogging) || ($.eventName = StopLogging) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventName = CreateTrail) || ($.eventName = UpdateTrail) || ($.eventName = DeleteTrail) || ($.eventName = StartLogging) || ($.eventName = StopLogging) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.6", + "Description": "Ensure AWS Management Console authentication failures are monitored", + "Checks": [ + "cloudwatch_log_metric_filter_authentication_failures" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be established for failed console authentication attempts.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring failed console logins may decrease the lead time to detect an attempt to brute-force a credential, which may provide an indicator, such as the source IP address, that can be used in other event correlations.", + "ImpactStatement": "Monitoring for these failures may generate a large number of alerts, especially in larger environments.", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for AWS management Console login failures and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventName = ConsoleLogin) && ($.errorMessage = Failed authentication) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventName = ConsoleLogin) && ($.errorMessage = Failed authentication) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.7", + "Description": "Ensure disabling or scheduled deletion of customer created CMKs is monitored", + "Checks": [ + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be established for customer-created CMKs that have changed state to disabled or are scheduled for deletion.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Data encrypted with disabled or deleted keys will no longer be accessible. Changes in the state of a CMK should be monitored to ensure that the change is intentional.", + "ImpactStatement": "Creation, storage, and management of CMK may require additional labor compared to the use of AWS-managed keys.", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for CMKs that have been disabled or scheduled for deletion and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{($.eventSource = kms.amazonaws.com) && (($.eventName=DisableKey)||($.eventName=ScheduleKeyDeletion)) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: {($.eventSource = kms.amazonaws.com) && (($.eventName=DisableKey)||($.eventName=ScheduleKeyDeletion)) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.8", + "Description": "Ensure S3 bucket policy changes are monitored", + "Checks": [ + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be established for changes to S3 bucket policies.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring changes to S3 bucket policies may reduce the time it takes to detect and correct permissive policies on sensitive S3 buckets.", + "ImpactStatement": "", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for changes to S3 bucket policies and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventSource = s3.amazonaws.com) && (($.eventName = PutBucketAcl) || ($.eventName = PutBucketPolicy) || ($.eventName = PutBucketCors) || ($.eventName = PutBucketLifecycle) || ($.eventName = PutBucketReplication) || ($.eventName = DeleteBucketPolicy) || ($.eventName = DeleteBucketCors) || ($.eventName = DeleteBucketLifecycle) || ($.eventName = DeleteBucketReplication)) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventSource = s3.amazonaws.com) && (($.eventName = PutBucketAcl) || ($.eventName = PutBucketPolicy) || ($.eventName = PutBucketCors) || ($.eventName = PutBucketLifecycle) || ($.eventName = PutBucketReplication) || ($.eventName = DeleteBucketPolicy) || ($.eventName = DeleteBucketCors) || ($.eventName = DeleteBucketLifecycle) || ($.eventName = DeleteBucketReplication)) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.9", + "Description": "Ensure AWS Config configuration changes are monitored", + "Checks": [ + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be established for detecting changes to AWS Config's configurations.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring changes to the AWS Config configuration will help ensure sustained visibility of the configuration items within the AWS account.", + "ImpactStatement": "", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for AWS Configuration changes and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventSource = config.amazonaws.com) && (($.eventName=StopConfigurationRecorder)||($.eventName=DeleteDeliveryChannel)||($.eventName=PutDeliveryChannel)||($.eventName=PutConfigurationRecorder)) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventSource = config.amazonaws.com) && (($.eventName=StopConfigurationRecorder)||($.eventName=DeleteDeliveryChannel)||($.eventName=PutDeliveryChannel)||($.eventName=PutConfigurationRecorder)) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.10", + "Description": "Ensure security group changes are monitored", + "Checks": [ + "cloudwatch_log_metric_filter_security_group_changes" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. Security groups are stateful packet filters that control ingress and egress traffic within a VPC. It is recommended that a metric filter and alarm be established to detect changes to security groups.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring changes to security groups will help ensure that resources and services are not unintentionally exposed.", + "ImpactStatement": "This may require additional 'tuning' to eliminate false positives and filter out expected activity so that anomalies are easier to detect.", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for security groups changes and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace=CISBenchmark,metricValue=1 --filter-pattern { ($.eventName = AuthorizeSecurityGroupIngress) || ($.eventName = AuthorizeSecurityGroupEgress) || ($.eventName = RevokeSecurityGroupIngress) || ($.eventName = RevokeSecurityGroupEgress) || ($.eventName = CreateSecurityGroup) || ($.eventName = DeleteSecurityGroup) || ($.eventName = ModifySecurityGroupRules) } ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace CISBenchmark --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventName = AuthorizeSecurityGroupIngress) || ($.eventName = AuthorizeSecurityGroupEgress) || ($.eventName = RevokeSecurityGroupIngress) || ($.eventName = RevokeSecurityGroupEgress) || ($.eventName = CreateSecurityGroup) || ($.eventName = DeleteSecurityGroup) || ($.eventName = ModifySecurityGroupRules) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query MetricAlarms[?MetricName==] ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored AWS has recently introduced a new API, ModifySecurityGroupRules, which modifies the rules of a security group.", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html:https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_ModifySecurityGroupRules.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.11", + "Description": "Ensure Network Access Control List (NACL) changes are monitored", + "Checks": [ + "cloudwatch_changes_to_network_acls_alarm_configured" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. NACLs are used as a stateless packet filter to control ingress and egress traffic for subnets within a VPC. It is recommended that a metric filter and alarm be established for any changes made to NACLs.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring changes to NACLs will help ensure that AWS resources and services are not unintentionally exposed.", + "ImpactStatement": "", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for NACL changes and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventName = CreateNetworkAcl) || ($.eventName = CreateNetworkAclEntry) || ($.eventName = DeleteNetworkAcl) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = ReplaceNetworkAclAssociation) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventName = CreateNetworkAcl) || ($.eventName = CreateNetworkAclEntry) || ($.eventName = DeleteNetworkAcl) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = ReplaceNetworkAclAssociation) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.12", + "Description": "Ensure changes to network gateways are monitored", + "Checks": [ + "cloudwatch_changes_to_network_gateways_alarm_configured" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. Network gateways are required to send and receive traffic to a destination outside of a VPC. It is recommended that a metric filter and alarm be established for changes to network gateways.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring changes to network gateways will help ensure that all ingress/egress traffic traverses the VPC border via a controlled path.", + "ImpactStatement": "Monitoring changes to network gateways helps detect unauthorized modifications that could compromise network security. Implementing automated monitoring and alerts can improve incident response times, but it may require additional configuration and maintenance efforts.", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for network gateways changes and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventName = CreateCustomerGateway) || ($.eventName = DeleteCustomerGateway) || ($.eventName = AttachInternetGateway) || ($.eventName = CreateInternetGateway) || ($.eventName = DeleteInternetGateway) || ($.eventName = DetachInternetGateway) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ``` 5. Implement logging and alerting mechanisms: ``` aws sns create-topic --name NetworkGatewayChangesAlerts ```` ``` aws sns subscribe --topic-arn --protocol email --notification-endpoint ``` ``` aws cloudwatch put-metric-alarm --alarm-name NetworkGatewayChangesAlarm --metric-name GatewayChanges --namespace AWS/EC2 --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventName = CreateCustomerGateway) || ($.eventName = DeleteCustomerGateway) || ($.eventName = AttachInternetGateway) || ($.eventName = CreateInternetGateway) || ($.eventName = DeleteInternetGateway) || ($.eventName = DetachInternetGateway) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::` 8. Ensure automated monitoring is enabled: ``` aws cloudwatch put-metric-alarm --alarm-name NetworkGatewayChanges --metric-name GatewayChanges --namespace AWS/EC2 --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --alarm-actions ```", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.13", + "Description": "Ensure route table changes are monitored", + "Checks": [ + "cloudwatch_changes_to_network_route_tables_alarm_configured" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. Routing tables are used to route network traffic between subnets and to network gateways. It is recommended that a metric filter and alarm be established for changes to route tables.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring changes to route tables will help ensure that all VPC traffic flows through the expected path and prevent any accidental or intentional modifications that may lead to uncontrolled network traffic. An alarm should be triggered every time an AWS API call is performed to create, replace, delete, or disassociate a route table.", + "ImpactStatement": "", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for route table changes and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-pattern '{ ($.eventName = CreateRoute) || ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRoute) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DisassociateRouteTable) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: {($.eventSource = ec2.amazonaws.com) && ($.eventName = CreateRoute) || ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRoute) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DisassociateRouteTable) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.14", + "Description": "Ensure VPC changes are monitored", + "Checks": [ + "cloudwatch_changes_to_vpcs_alarm_configured" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is possible to have more than one VPC within an account; additionally, it is also possible to create a peer connection between two VPCs, enabling network traffic to route between them. It is recommended that a metric filter and alarm be established for changes made to VPCs.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. VPCs in AWS are logically isolated virtual networks that can be used to launch AWS resources. Monitoring changes to VPC configurations will help ensure that VPC traffic flow is not negatively impacted. Changes to VPCs can affect network accessibility from the public internet and additionally impact VPC traffic flow to and from the resources launched in the VPC.", + "ImpactStatement": "", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for VPC changes and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventName = CreateVpc) || ($.eventName = DeleteVpc) || ($.eventName = ModifyVpcAttribute) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = DisableVpcClassicLink) || ($.eventName = EnableVpcClassicLink) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventName = CreateVpc) || ($.eventName = DeleteVpc) || ($.eventName = ModifyVpcAttribute) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = DisableVpcClassicLink) || ($.eventName = EnableVpcClassicLink) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.15", + "Description": "Ensure AWS Organizations changes are monitored", + "Checks": [ + "cloudwatch_log_metric_filter_aws_organizations_changes" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be established for changes made to AWS Organizations in the master AWS account.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring AWS Organizations changes can help you prevent unwanted, accidental, or intentional modifications that may lead to unauthorized access or other security breaches. This monitoring technique helps ensure that any unexpected changes made within your AWS Organizations can be investigated and that any unwanted changes can be rolled back.", + "ImpactStatement": "", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for AWS Organizations changes and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventSource = organizations.amazonaws.com) && (($.eventName = AcceptHandshake) || ($.eventName = AttachPolicy) || ($.eventName = CreateAccount) || ($.eventName = CreateOrganizationalUnit) || ($.eventName = CreatePolicy) || ($.eventName = DeclineHandshake) || ($.eventName = DeleteOrganization) || ($.eventName = DeleteOrganizationalUnit) || ($.eventName = DeletePolicy) || ($.eventName = DetachPolicy) || ($.eventName = DisablePolicyType) || ($.eventName = EnablePolicyType) || ($.eventName = InviteAccountToOrganization) || ($.eventName = LeaveOrganization) || ($.eventName = MoveAccount) || ($.eventName = RemoveAccountFromOrganization) || ($.eventName = UpdatePolicy) || ($.eventName = UpdateOrganizationalUnit)) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventSource = organizations.amazonaws.com) && (($.eventName = AcceptHandshake) || ($.eventName = AttachPolicy) || ($.eventName = CreateAccount) || ($.eventName = CreateOrganizationalUnit) || ($.eventName = CreatePolicy) || ($.eventName = DeclineHandshake) || ($.eventName = DeleteOrganization) || ($.eventName = DeleteOrganizationalUnit) || ($.eventName = DeletePolicy) || ($.eventName = DetachPolicy) || ($.eventName = DisablePolicyType) || ($.eventName = EnablePolicyType) || ($.eventName = InviteAccountToOrganization) || ($.eventName = LeaveOrganization) || ($.eventName = MoveAccount) || ($.eventName = RemoveAccountFromOrganization) || ($.eventName = UpdatePolicy) || ($.eventName = UpdateOrganizationalUnit)) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/organizations/latest/userguide/orgs_security_incident-response.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.16", + "Description": "Ensure AWS Security Hub is enabled", + "Checks": [ + "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Security Hub collects security data from various AWS accounts, services, and supported third-party partner products, helping you analyze your security trends and identify the highest-priority security issues. When you enable Security Hub, it begins to consume, aggregate, organize, and prioritize findings from the AWS services that you have enabled, such as Amazon GuardDuty, Amazon Inspector, and Amazon Macie. You can also enable integrations with AWS partner security products.", + "RationaleStatement": "AWS Security Hub provides you with a comprehensive view of your security state in AWS and helps you check your environment against security industry standards and best practices, enabling you to quickly assess the security posture across your AWS accounts.", + "ImpactStatement": "It is recommended that AWS Security Hub be enabled in all regions. AWS Security Hub requires that AWS Config be enabled.", + "RemediationProcedure": "To grant the permissions required to enable Security Hub, attach the Security Hub managed policy `AWSSecurityHubFullAccess` to an IAM user, group, or role. Enabling Security Hub: **From Console:** 1. Use the credentials of the IAM identity to sign in to the Security Hub console. 2. When you open the Security Hub console for the first time, choose `Go to Security Hub`. 3. The `Security standards` section on the welcome page lists supported security standards. Check the box for a standard to enable it. 3. Choose `Enable Security Hub`. **From Command Line:** 1. Run the `enable-security-hub` command, including `--enable-default-standards` to enable the default standards: ``` aws securityhub enable-security-hub --enable-default-standards ``` 2. To enable Security Hub without the default standards, include `--no-enable-default-standards`: ``` aws securityhub enable-security-hub --no-enable-default-standards ```", + "AuditProcedure": "Follow this process to evaluate AWS Security Hub configuration per region: **From Console:** 1. Sign in to the AWS Management Console and open the AWS Security Hub console at https://console.aws.amazon.com/securityhub/. 2. On the top right of the console, select the target Region. 3. If the Security Hub > Summary page is displayed, then Security Hub is set up for the selected region. 4. If presented with Setup Security Hub or Get Started With Security Hub, refer to the remediation steps. 5. Repeat steps 2 to 4 for each region. **From Command Line:** Run the following command to verify the Security Hub status: ``` aws securityhub describe-hub ``` This will list the Security Hub status by region. Check for a 'SubscribedAt' value. Example output: ``` { HubArn: , SubscribedAt: 2022-08-19T17:06:42.398Z, AutoEnableControls: true } ``` An error will be returned if Security Hub is not enabled. Example error: ``` An error occurred (InvalidAccessException) when calling the DescribeHub operation: Account is not subscribed to AWS Security Hub ```", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-get-started.html:https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-enable.html#securityhub-enable-api:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/securityhub/enable-security-hub.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.1.1", + "Description": "Ensure EBS volume encryption is enabled in all regions", + "Checks": [ + "ec2_ebs_default_encryption" + ], + "Attributes": [ + { + "Section": "6 Networking", + "SubSection": "6.1 Elastic Compute Cloud (EC2)", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Elastic Compute Cloud (EC2) supports encryption at rest when using the Elastic Block Store (EBS) service. While disabled by default, forcing encryption at EBS volume creation is supported.", + "RationaleStatement": "Encrypting data at rest reduces the likelihood of unintentional exposure and can nullify the impact of disclosure if the encryption remains unbroken.", + "ImpactStatement": "Losing access to or removing the KMS key used by the EBS volumes will result in the inability to access the volumes.", + "RemediationProcedure": "**From Console:** 1. Login to the AWS Management Console and open the Amazon EC2 console using https://console.aws.amazon.com/ec2/. 2. Under `Account attributes`, click `EBS encryption`. 3. Click `Manage`. 4. Check the `Enable` box. 5. Click `Update EBS encryption`. 6. Repeat for each region in which EBS volume encryption is not enabled by default. **Note:** EBS volume encryption is configured per region. **From Command Line:** 1. Run the following command: ``` aws --region ec2 enable-ebs-encryption-by-default ``` 2. Verify that `EbsEncryptionByDefault: true` is displayed. 3. Repeat for each region in which EBS volume encryption is not enabled by default. **Note:** EBS volume encryption is configured per region.", + "AuditProcedure": "**From Console:** 1. Login to the AWS Management Console and open the Amazon EC2 console using https://console.aws.amazon.com/ec2/. 2. Under `Settings`, click `EBS encryption`. 3. Verify `Always encrypt new EBS volumes` displays `Enabled`. 4. Repeat for each region in use. **Note:** EBS volume encryption is configured per region. **From Command Line:** 1. Run the following command: ``` aws --region ec2 get-ebs-encryption-by-default ``` 2. Verify that `EbsEncryptionByDefault: true` is displayed. 3. Repeat for each region in use. **Note:** EBS volume encryption is configured per region.", + "AdditionalInformation": "Default EBS volume encryption only applies to newly created EBS volumes; existing EBS volumes are **not** converted automatically.", + "References": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html:https://aws.amazon.com/blogs/aws/new-opt-in-to-default-encryption-for-new-ebs-volumes/", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.1.2", + "Description": "Ensure CIFS access is restricted to trusted networks to prevent unauthorized access", + "Checks": [ + "ec2_instance_port_cifs_exposed_to_internet" + ], + "Attributes": [ + { + "Section": "6 Networking", + "SubSection": "6.1 Elastic Compute Cloud (EC2)", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Common Internet File System (CIFS) is a network file-sharing protocol that allows systems to share files over a network. However, unrestricted CIFS access can expose your data to unauthorized users, leading to potential security risks. It is important to restrict CIFS access to only trusted networks and users to prevent unauthorized access and data breaches.", + "RationaleStatement": "Allowing unrestricted CIFS access can lead to significant security vulnerabilities, as it may allow unauthorized users to access sensitive files and data. By restricting CIFS access to known and trusted networks, you can minimize the risk of unauthorized access and protect sensitive data from exposure to potential attackers. Implementing proper network access controls and permissions is essential for maintaining the security and integrity of your file-sharing systems.", + "ImpactStatement": "Restricting CIFS access may require additional configuration and management effort. However, the benefits of enhanced security and reduced risk of unauthorized access to sensitive data far outweigh the potential challenges.", + "RemediationProcedure": "**From Console:** 1. Login to the AWS Management Console. 2. Navigate to the EC2 Dashboard and select the Security Groups section under `Network & Security`. 3. Identify the security group that allows unrestricted ingress on port 445. 4. Select the security group and click the `Edit Inbound Rules` button. 5. Locate the rule allowing unrestricted access on port 445 (typically listed as `0.0.0.0/0` or `::/0`). 6. Modify the rule to restrict access to specific IP ranges or trusted networks only. 7. Save the changes to the security group. **From Command Line:** 1. Run the following command to remove or modify the unrestricted rule for CIFS access: ``` aws ec2 revoke-security-group-ingress --region --group-id --protocol tcp --port 445 --cidr 0.0.0.0/0 ``` - Optionally, run the `authorise-security-group-ingress` command to create a new rule, specifying a trusted CIDR range instead of `0.0.0.0/0`. 2. Confirm the changes by describing the security group again and ensuring the unrestricted access rule has been removed or appropriately restricted: ``` aws ec2 describe-security-groups --region --group-ids --query 'SecurityGroups[*].IpPermissions[?ToPort==`445`].{CIDR:IpRanges[*].CidrIp,Port:ToPort}' ``` 3. Repeat the remediation for other security groups and regions as necessary.", + "AuditProcedure": "**From Console:** 1. Login to the AWS Management Console. 2. Navigate to the EC2 Dashboard and select the Security Groups section under `Network & Security`. 3. Identify the security groups associated with instances or resources that may be using CIFS. 4. Review the inbound rules of each security group to check for rules that allow unrestricted access on port 445 (the port used by CIFS). - Specifically, look for inbound rules that allow access from `0.0.0.0/0` or `::/0` on port 445. 5. Document any instances where unrestricted access is allowed and verify whether it is necessary for the specific use case. **From Command Line:** 1. Run the following command to list all security groups and identify those associated with CIFS: ``` aws ec2 describe-security-groups --region --query 'SecurityGroups[*].GroupId' ``` 2. Check for any inbound rules that allow unrestricted access on port 445 using the following command: ``` aws ec2 describe-security-groups --region --group-ids --query 'SecurityGroups[*].IpPermissions[?ToPort==`445`].{CIDR:IpRanges[*].CidrIp,Port:ToPort}' ``` - Look for `0.0.0.0/0` or `::/0` in the output, which indicates unrestricted access. 3. Repeat the audit for other regions and security groups as necessary.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.2", + "Description": "Ensure no Network ACLs allow ingress from 0.0.0.0/0 to remote server administration ports", + "Checks": [ + "ec2_networkacl_allow_ingress_any_port", + "ec2_networkacl_allow_ingress_tcp_port_22", + "ec2_networkacl_allow_ingress_tcp_port_3389" + ], + "Attributes": [ + { + "Section": "6 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The Network Access Control List (NACL) function provides stateless filtering of ingress and egress network traffic to AWS resources. It is recommended that no NACL allows unrestricted ingress access to remote server administration ports, such as SSH on port `22` and RDP on port `3389`, using either the TCP (6), UDP (17), or ALL (-1) protocols.", + "RationaleStatement": "Public access to remote server administration ports, such as 22 (when used for SSH, not SFTP) and 3389, increases the attack surface of resources and unnecessarily raises the risk of resource compromise.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** Perform the following steps to remediate a network ACL: 1. Login to the AWS VPC Console at https://console.aws.amazon.com/vpc/home. 2. In the left pane, click `Network ACLs`. 3. For each network ACL that needs remediation, perform the following: - Select the network ACL. - Click the `Inbound Rules` tab. - Click `Edit inbound rules`. - Either A) update the Source field to a range other than 0.0.0.0/0, or B) click `Delete` to remove the offending inbound rule. - Click `Save`.", + "AuditProcedure": "**From Console:** Perform the following steps to determine if the account is configured as prescribed: 1. Login to the AWS VPC Console at https://console.aws.amazon.com/vpc/home. 2. In the left pane, click `Network ACLs`. 3. For each network ACL, perform the following: - Select the network ACL. - Click the `Inbound Rules` tab. - Ensure that no rule exists which has a port range that includes port `22` or `3389`, uses the protocols TCP (6), UDP (17), or ALL (-1), or other remote server administration ports for your environment, has a `Source` of `0.0.0.0/0`, and shows `ALLOW`. **Note:** A port value of `ALL` or a port range such as `0-3389` includes port `22`, `3389`, and potentially other remote server administration ports.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html:https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Security.html#VPC_Security_Comparison", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.3", + "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 to remote server administration ports", + "Checks": [ + "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389" + ], + "Attributes": [ + { + "Section": "6 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Security groups provide stateful filtering of ingress and egress network traffic to AWS resources. It is recommended that no security group allows unrestricted ingress access to remote server administration ports, such as SSH on port `22` and RDP on port `3389`, using either the TCP (6), UDP (17), or ALL (-1) protocols.", + "RationaleStatement": "Public access to remote server administration ports, such as 22 (when used for SSH, not SFTP) and 3389, increases the attack surface of resources and unnecessarily raises the risk of resource compromise.", + "ImpactStatement": "When updating an existing environment, ensure that administrators have access to remote server administration ports through another mechanism before removing access by deleting the 0.0.0.0/0 inbound rule.", + "RemediationProcedure": "Perform the following to implement the prescribed state: 1. Login to the AWS VPC Console at [https://console.aws.amazon.com/vpc/home](https://console.aws.amazon.com/vpc/home). 2. In the left pane, click `Security Groups`. 3. For each security group, perform the following: - Select the security group. - Click the `Inbound Rules` tab. - Click the `Edit inbound rules` button. - Identify the rules to be edited or removed. - Either A) update the Source field to a range other than 0.0.0.0/0, or B) click `Delete` to remove the offending inbound rule. - Click `Save rules`.", + "AuditProcedure": "Perform the following to determine if the account is configured as prescribed: 1. Login to the AWS VPC Console at [https://console.aws.amazon.com/vpc/home](https://console.aws.amazon.com/vpc/home). 2. In the left pane, click `Security Groups`. 3. For each security group, perform the following: - Select the security group. - Click the `Inbound Rules` tab. - Ensure that no rule exists which has a port range including port `22` or `3389`, uses the protocols TCP (6), UDP (17), or ALL (-1), or other remote server administration ports for your environment, and has a `Source` of `0.0.0.0/0`. **Note:** A port value of `ALL` or a port range such as `0-3389` includes port `22`, `3389`, and potentially other remote server administration ports.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-security-groups.html#deleting-security-group-rule", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.4", + "Description": "Ensure no security groups allow ingress from ::/0 to remote server administration ports", + "Checks": [ + "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389" + ], + "Attributes": [ + { + "Section": "6 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Security groups provide stateful filtering of ingress and egress network traffic to AWS resources. It is recommended that no security group allows unrestricted ingress access to remote server administration ports, such as SSH on port `22` and RDP on port `3389`.", + "RationaleStatement": "Public access to remote server administration ports, such as 22 (when used for SSH, not SFTP) and 3389, increases attack surface of resources and unnecessarily raises the risk of resource compromise.", + "ImpactStatement": "When updating an existing environment, ensure that administrators have access to remote server administration ports through another mechanism before removing access by deleting the ::/0 inbound rule.", + "RemediationProcedure": "Perform the following to implement the prescribed state: 1. Login to the AWS VPC Console at [https://console.aws.amazon.com/vpc/home](https://console.aws.amazon.com/vpc/home). 2. In the left pane, click `Security Groups`. 3. For each security group, perform the following: - Select the security group. - Click the `Inbound Rules` tab. - Click the `Edit inbound rules` button. - Identify the rules to be edited or removed. - Either A) update the Source field to a range other than ::/0, or B) Click `Delete` to remove the offending inbound rule. - Click `Save rules`.", + "AuditProcedure": "Perform the following to determine if the account is configured as prescribed: 1. Login to the AWS VPC Console at [https://console.aws.amazon.com/vpc/home](https://console.aws.amazon.com/vpc/home). 2. In the left pane, click `Security Groups`. 3. For each security group, perform the following: - Select the security group. - Click the `Inbound Rules` tab. - Ensure that no rule exists which has a port range including port `22`, `3389`, or other remote server administration ports for your environment, and has a `Source` of `::/0`. **Note:** A port value of `ALL` or a port range such as `0-3389` includes port `22`, `3389`, and potentially other remote server administration ports.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-security-groups.html#deleting-security-group-rule", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.5", + "Description": "Ensure the default security group of every VPC restricts all traffic", + "Checks": [ + "ec2_securitygroup_default_restrict_traffic" + ], + "Attributes": [ + { + "Section": "6 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "A VPC comes with a default security group whose initial settings deny all inbound traffic, allow all outbound traffic, and allow all traffic between instances assigned to the security group. If a security group is not specified when an instance is launched, it is automatically assigned to this default security group. Security groups provide stateful filtering of ingress/egress network traffic to AWS resources. It is recommended that the default security group restrict all traffic, both inbound and outbound. The default VPC in every region should have its default security group updated to comply with the following: - No inbound rules. - No outbound rules. Any newly created VPCs will automatically contain a default security group that will need remediation to comply with this recommendation. **Note:** When implementing this recommendation, VPC flow logging is invaluable in determining the least privilege port access required by systems to work properly, as it can log all packet acceptances and rejections occurring under the current security groups. This dramatically reduces the primary barrier to least privilege engineering by discovering the minimum ports required by systems in the environment. Even if the VPC flow logging recommendation in this benchmark is not adopted as a permanent security measure, it should be used during any period of discovery and engineering for least privileged security groups.", + "RationaleStatement": "Configuring all VPC default security groups to restrict all traffic will encourage the development of least privilege security groups and promote the mindful placement of AWS resources into security groups, which will, in turn, reduce the exposure of those resources.", + "ImpactStatement": "Implementing this recommendation in an existing VPC that contains operating resources requires extremely careful migration planning, as the default security groups are likely enabling many ports that are unknown. Enabling VPC flow logging (for accepted connections) in an existing environment that is known to be breach-free will reveal the current pattern of ports being used for each instance to communicate successfully. The migration process should include: - Analyzing VPC flow logs to understand current traffic patterns. - Creating least privilege security groups based on the analyzed data. - Testing the new security group rules in a staging environment before applying them to production.", + "RemediationProcedure": "Perform the following to implement the prescribed state: **Security Group Members** 1. Identify AWS resources that exist within the default security group. 2. Create a set of least-privilege security groups for those resources. 3. Place the resources in those security groups, removing the resources noted in step 1 from the default security group. **Security Group State** 1. Login to the AWS VPC Console at [https://console.aws.amazon.com/vpc/home](https://console.aws.amazon.com/vpc/home). 2. Repeat the following steps for all VPCs, including the default VPC in each AWS region: 3. In the left pane, click `Security Groups`. 4. For each default security group, perform the following: - Select the `default` security group. - Click the `Inbound Rules` tab. - Remove any inbound rules. - Click the `Outbound Rules` tab. - Remove any Outbound rules. **Recommended** IAM groups allow you to edit the name field. After remediating default group rules for all VPCs in all regions, edit this field to add text similar to DO NOT USE. DO NOT ADD RULES.", + "AuditProcedure": "Perform the following to determine if the account is configured as prescribed: **Security Group State** 1. Login to the AWS VPC Console at [https://console.aws.amazon.com/vpc/home](https://console.aws.amazon.com/vpc/home). 2. Repeat the following steps for all VPCs, including the default VPC in each AWS region: 3. In the left pane, click `Security Groups`. 4. For each default security group, perform the following: - Select the `default` security group. - Click the `Inbound Rules` tab and ensure no rules exist. - Click the `Outbound Rules` tab and ensure no rules exist. **Security Group Members** 1. Login to the AWS VPC Console at [https://console.aws.amazon.com/vpc/home](https://console.aws.amazon.com/vpc/home). 2. Repeat the following steps for all default groups in all VPCs, including the default VPC in each AWS region: 3. In the left pane, click `Security Groups`. 4. Copy the ID of the default security group. 5. Change to the EC2 Management Console at https://console.aws.amazon.com/ec2/v2/home. 6. In the filter column type `Security Group ID : `.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-network-security.html:https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-security-groups.html#default-security-group", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.6", + "Description": "Ensure routing tables for VPC peering are \"least access\"", + "Checks": [ + "vpc_peering_routing_tables_with_least_privilege" + ], + "Attributes": [ + { + "Section": "6 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Once a VPC peering connection is established, routing tables must be updated to enable any connections between the peered VPCs. These routes can be as specific as desired, even allowing for the peering of a VPC to only a single host on the other side of the connection.", + "RationaleStatement": "Being highly selective in peering routing tables is a very effective way to minimize the impact of a breach, as resources outside of these routes are inaccessible to the peered VPC.", + "ImpactStatement": "", + "RemediationProcedure": "Remove and add route table entries to ensure that the least number of subnets or hosts required to accomplish the purpose of peering are routable. **From Command Line:** 1. For each `` that contains routes that are non-compliant with your routing policy (granting more access than desired), delete the non-compliant route: ``` aws ec2 delete-route --route-table-id --destination-cidr-block ``` 2. Create a new compliant route: ``` aws ec2 create-route --route-table-id --destination-cidr-block --vpc-peering-connection-id ```", + "AuditProcedure": "Review the routing tables of peered VPCs to determine whether they route all subnets of each VPC and whether this is necessary to accomplish the intended purposes of peering the VPCs. **From Command Line:** 1. List all the route tables from a VPC and check if the GatewayId is pointing to a `` (e.g., pcx-1a2b3c4d) and if the DestinationCidrBlock is as specific as desired: ``` aws ec2 describe-route-tables --filter Name=vpc-id,Values= --query RouteTables[*].{RouteTableId:RouteTableId, VpcId:VpcId, Routes:Routes, AssociatedSubnets:Associations[*].SubnetId} ```", + "AdditionalInformation": "If an organization has an AWS Transit Gateway implemented in its VPC architecture, it should look to apply the recommendation above for a least access routing architecture at the AWS Transit Gateway level, in combination with what must be implemented at the standard VPC route table. More specifically, to route traffic between two or more VPCs via a Transit Gateway, VPCs must have an attachment to a Transit Gateway route table as well as a route. Therefore, to avoid routing traffic between VPCs, an attachment to the Transit Gateway route table should only be added where there is an intention to route traffic between the VPCs. As Transit Gateways are capable of hosting multiple route tables, it is possible to group VPCs by attaching them to a common route table.", + "References": "https://docs.aws.amazon.com/AmazonVPC/latest/PeeringGuide/peering-configurations-partial-access.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ec2/create-vpc-peering-connection.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.7", + "Description": "Ensure that the EC2 Metadata Service only allows IMDSv2", + "Checks": [ + "ec2_instance_imdsv2_enabled" + ], + "Attributes": [ + { + "Section": "6 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "When enabling the Metadata Service on AWS EC2 instances, users have the option of using either Instance Metadata Service Version 1 (IMDSv1; a request/response method) or Instance Metadata Service Version 2 (IMDSv2; a session-oriented method).", + "RationaleStatement": "Instance metadata is data about your instance that you can use to configure or manage the running instance. Instance metadata is divided into [categories](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html), such as host name, events, and security groups. When enabling the Metadata Service on AWS EC2 instances, users have the option of using either Instance Metadata Service Version 1 (IMDSv1; a request/response method) or Instance Metadata Service Version 2 (IMDSv2; a session-oriented method). With IMDSv2, every request is now protected by session authentication. A session begins and ends a series of requests that software running on an EC2 instance uses to access the locally stored EC2 instance metadata and credentials. Allowing Version 1 of the service may open EC2 instances to Server-Side Request Forgery (SSRF) attacks, so Amazon recommends utilizing Version 2 for better instance security.", + "ImpactStatement": "", + "RemediationProcedure": "From Console: 1. Sign in to the AWS Management Console and navigate to the EC2 dashboard at [https://console.aws.amazon.com/ec2/](https://console.aws.amazon.com/ec2/). 2. In the left navigation panel, under the `INSTANCES` section, choose `Instances`. 3. Select the EC2 instance that you want to examine. 4. Choose `Actions > Instance Settings > Modify instance metadata options`. 5. Set `Instance metadata service` to `Enable`. 6. Set `IMDSv2` to `Required`. 7. Repeat steps 1-6 to perform the remediation process for other EC2 instances in all applicable AWS region(s). From Command Line: 1. Run the `describe-instances` command, applying the appropriate filters to list the IDs of all existing EC2 instances currently available in the selected region: ``` aws ec2 describe-instances --region --output table --query Reservations[*].Instances[*].InstanceId ``` 2. The command output should return a table with the requested instance IDs. 3. Run the `modify-instance-metadata-options` command with an instance ID obtained from the previous step to update the Instance Metadata Version: ``` aws ec2 modify-instance-metadata-options --instance-id --http-tokens required --region ``` 4. Repeat steps 1-3 to perform the remediation process for other EC2 instances in the same AWS region. 5. Change the region by updating `--region` and repeat the process for other regions.", + "AuditProcedure": "From Console: 1. Sign in to the AWS Management Console and navigate to the EC2 dashboard at https://console.aws.amazon.com/ec2/. 2. In the left navigation panel, under the `INSTANCES` section, choose `Instances`. 3. Select the EC2 instance that you want to examine. 4. Check the `IMDSv2` status, and ensure that it is set to `Required`. From Command Line: 1. Run the `describe-instances` command using appropriate filters to list the IDs of all existing EC2 instances currently available in the selected region: ``` aws ec2 describe-instances --region --output table --query Reservations[*].Instances[*].InstanceId ``` 2. The command output should return a table with the requested instance IDs. 3. Run the `describe-instances` command using the instance ID returned in the previous step and apply custom filtering to determine whether the selected instance is using IMDSv2: ``` aws ec2 describe-instances --region --instance-ids --query Reservations[*].Instances[*].MetadataOptions --output table ``` 4. Ensure that for all EC2 instances, `HttpTokens` is set to `required` and `State` is set to `applied`. 5. Repeat steps 3 and 4 to verify the other EC2 instances provisioned within the current region. 6. Repeat steps 1–5 to perform the audit process for other AWS regions.", + "AdditionalInformation": "", + "References": "https://aws.amazon.com/blogs/security/defense-in-depth-open-firewalls-reverse-proxies-ssrf-vulnerabilities-ec2-instance-metadata-service/:https://docs.aws.amazon.com/cli/latest/reference/ec2/describe-instances.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.8", + "Description": "Ensure VPC Endpoints are used for access to AWS Services", + "Checks": [], + "Attributes": [ + { + "Section": "6 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that Amazon VPCs use VPC endpoints (gateway or interface endpoints) for access to AWS services such as Amazon S3 and DynamoDB, so that traffic from workloads to AWS services stays on the Amazon private network instead of traversing the public internet. VPC endpoints provide private connectivity between VPCs and supported AWS services without requiring an internet gateway, NAT gateway, or public IP addresses.", + "RationaleStatement": "Accessing AWS services over the public internet increases exposure to network-level threats, relies on internet routing, and makes it harder to tightly control egress paths. Using VPC endpoints allows workloads to reach AWS services over the Amazon private network, which reduces reliance on internet gateways and NAT gateways, simplifies egress filtering, and helps enforce data-perimeter and \"private-only\" patterns for sensitive workloads.", + "ImpactStatement": "Enforcing the use of VPC endpoints may require changes to existing network architectures, including creating and managing endpoints in each VPC, updating route tables, adjusting security groups, and potentially removing or tightening some internet/NAT gateway paths. This can introduce additional operational overhead and cost (per-endpoint charges for interface endpoints) and may require updates to IaC templates and deployment pipelines.", + "RemediationProcedure": "In this example, we are going to add S3 gateway endpoint and SQS interface endpoint to a VPC. You can follow similar remediation instructions for other services. 1. Create S3 Gateway Endpoint ``` aws ec2 create-vpc-endpoint --region REGION --route-table-ids ROUTE_TABLE_ID --vpc-id VPC_ID --service-name com.amazonaws.REGION.s3 --vpc-endpoint-type Gateway --query \"VpcEndpoint.VpcEndpointId\" --output text ``` - Provide values for REGION, ROUTE_TABLE_ID, VPC_ID - AWS automatically creates the routes for the AWS service in the route table provided as part of above command. 2. Verify that the gateway routes have been adequately created ``` aws ec2 describe-route-tables --region REGION --route-table-ids ROUTE_TABLE_ID --query \"RouteTables[0].Routes[?DestinationPrefixListId=='pl-xxxxxxxx']\" ``` - Provide values for REGION, ROUTE_TABLE_ID - pl-xxxxxxxx: replace with the specific prefix list for S3 in that region 3. Create an SQS Interface Endpoint ``` aws ec2 create-vpc-endpoint --vpc-id VPC_ID --service-name com.amazonaws.REGION.sqs --vpc-endpoint-type Interface --subnet-ids PRIVATE_SUBNET_1_ID PRIVATE_SUBNET_2_ID --security-group-ids SECURITY_GROUP_ID --vpc-endpoint-policy VPC_ENDPOINT_POLICY --query \"VpcEndpoint.VpcEndpointId\" --output text ``` - SECURITY_GROUP_ID: Update security groups for interface endpoint. Ensure the interface endpoint security group allows inbound traffic from your workloads. - VPC_ENDPOINT_POLICY: Create a restrictive Endpoint policy to ensure only certain AWS services could be reached and only specific actions can be performed. - AWS automatically creates Elastic Network Interfaces (ENIs) for the interface endpoint which allows any traffic from PRIVATE_SUBNET_1_ID PRIVATE_SUBNET_2_ID intended for SQS to be routed through the Interface Gateway. 4. Test and validate endpoint connectivity from an EC2 instance in a private subnet: - Test S3 (gateway endpoint) ``` aws s3 ls s3://your-test-bucket --region REGION ``` - Test SQS (interface endpoint) ``` aws sqs list-queues --region REGION ```", + "AuditProcedure": "1. Identify in-scope VPCs and services. - Determine which VPCs host production or sensitive workloads that should access AWS services securely via endpoints. - For those VPCs, identify the AWS services they depend on (for example, S3 for data storage, DynamoDB for database, etc.). 2. For each in-scope VPC, check for existing VPC endpoints. ``` aws ec2 describe-vpc-endpoints --region REGION --filters \"Name=vpc-id,Values=VPC_ID\" --query \"VpcEndpoints[*].[VpcEndpointId,VpcEndpointType,ServiceName,State]\" --output table ``` - Provide the REGION and VPC_ID - `VpcEndpointType` tells you whether the endpoint is Gateway or Interface. - `ServiceName` shows which AWS service the endpoint is for (for example, com.amazonaws.us-east-1.s3, com.amazonaws.us-east-1.dynamodb, com.amazonaws.us-east-1.ssm). 3. For each interface endpoint, verify subnet attachment across relevant AZs/subnets. ``` aws ec2 describe-vpc-endpoints --region REGION --vpc-endpoint-ids INTERFACE_ENDPOINT_ID --query \"VpcEndpoints[*].[VpcEndpointId,ServiceName,SubnetIds,State]\" --output json ``` - Provide the REGION and INTERFACE_ENDPOINT_ID 4. For each gateway endpoint, verify that the route tables for the relevant subnets send traffic to the endpoint (via the AWS-managed prefix list), not via internet/NAT gateways. - Identify relevant subnets in the VPC that need to have a route to gateway endpoint: ``` aws ec2 describe-subnets --region REGION --filters \"Name=vpc-id,Values=,VPC_ID\" --query \"Subnets[*].[SubnetId,AvailabilityZone,MapPublicIpOnLaunch,CidrBlock]\" --output table ``` - Provide the REGION and VPC_ID - For each relevant subnet, identify the route table associated with it: ``` aws ec2 describe-route-tables --region REGION --filters \"Name=association.subnet-id,Values=SUBNET_ID\" --query \"RouteTables[*].RouteTableId\" --output text ``` - Provide the REGION and SUBNET_ID - For each route table associated with relevant subnets, inspect routes: ``` aws ec2 describe-route-tables --region REGION --route-table-ids ROUTE_TABLE_ID --query \"RouteTables[0].Routes[*].[DestinationPrefixListId,GatewayId,NatGatewayId,State]\" --output table ``` - Provide the REGION and ROUTE_TABLE_ID For S3/DynamoDB gateway endpoints, you should see a `DestinationPrefixListId` (for example, pl-xxxxxxxx) with `GatewayId` equal to the endpoint (vpce-xxxx). If S3/DynamoDB are used by workloads in those subnets but traffic is only routed via igw-xxxx or nat-xxxx (and no prefix-list/endpoint route exists), then VPC endpoints are not being used for securing network traffic for these services.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + } + ] +} \ No newline at end of file diff --git a/prowler/compliance/aws/cisa_aws.json b/prowler/compliance/aws/cisa_aws.json index fa8e27a061..27b06a1ea4 100644 --- a/prowler/compliance/aws/cisa_aws.json +++ b/prowler/compliance/aws/cisa_aws.json @@ -136,6 +136,20 @@ "ec2_securitygroup_default_restrict_traffic", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", "ec2_securitygroup_allow_ingress_from_internet_to_all_ports" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -367,6 +381,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/ens_rd2022_aws.json b/prowler/compliance/aws/ens_rd2022_aws.json index 8fcd3263e9..144437ce52 100644 --- a/prowler/compliance/aws/ens_rd2022_aws.json +++ b/prowler/compliance/aws/ens_rd2022_aws.json @@ -598,6 +598,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -624,6 +632,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -755,6 +771,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -781,6 +805,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -913,6 +945,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -940,6 +980,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -966,6 +1014,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1743,6 +1799,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1821,6 +1885,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1873,6 +1945,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1925,6 +2005,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1951,6 +2039,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1977,6 +2073,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2003,6 +2107,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2056,6 +2168,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2082,6 +2202,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4310,6 +4438,14 @@ ], "Checks": [ "drs_job_exist" + ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json b/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json index 6c6c500582..15763ef48e 100644 --- a/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json +++ b/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json @@ -37,6 +37,14 @@ "ssm_managed_compliant_patching", "ssm_managed_instance_compliance_association_compliant", "ssm_managed_instance_compliance_patch_compliant" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -146,6 +154,20 @@ "inspector2_active_findings_exist", "securityhub_enabled", "sns_topics_kms_encryption_at_rest_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -205,6 +227,14 @@ "resourceexplorer_indexes_found", "ssm_managed_instance_compliance_association_compliant", "trustedadvisor_premium_support_plan_subscribed" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -349,6 +379,14 @@ "config_recorder_all_regions_enabled", "inspector2_is_enabled", "resourceexplorer_indexes_found" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] } ] diff --git a/prowler/compliance/aws/fedramp_low_revision_4_aws.json b/prowler/compliance/aws/fedramp_low_revision_4_aws.json index 9824798552..059de69675 100644 --- a/prowler/compliance/aws/fedramp_low_revision_4_aws.json +++ b/prowler/compliance/aws/fedramp_low_revision_4_aws.json @@ -46,6 +46,20 @@ "redshift_cluster_audit_logging", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -115,6 +129,20 @@ "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", "ec2_networkacl_allow_ingress_any_port" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -173,6 +201,14 @@ ], "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled" + ], + "ConfigRequirements": [ + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 90 + } ] }, { @@ -198,6 +234,20 @@ "rds_instance_enhanced_monitoring_enabled", "redshift_cluster_audit_logging", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -251,6 +301,14 @@ "guardduty_is_enabled", "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -336,6 +394,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -373,6 +445,14 @@ "rds_instance_multi_az", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json b/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json index 11f66ada6b..eaa3ea25dc 100644 --- a/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json +++ b/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json @@ -36,6 +36,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -65,6 +79,20 @@ "redshift_cluster_audit_logging", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -82,6 +110,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -140,6 +182,20 @@ "redshift_cluster_audit_logging", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -191,6 +247,38 @@ "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" + ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_role_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_access_not_stale_to_sagemaker", + "ConfigKey": "max_unused_sagemaker_access_days", + "Operator": "lte", + "Value": 90 + } ] }, { @@ -371,6 +459,20 @@ "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", "ec2_networkacl_allow_ingress_any_port" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -507,6 +609,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -575,6 +691,14 @@ ], "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled" + ], + "ConfigRequirements": [ + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 90 + } ] }, { @@ -631,6 +755,20 @@ "rds_instance_enhanced_monitoring_enabled", "redshift_cluster_audit_logging", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -720,6 +858,14 @@ "guardduty_is_enabled", "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -887,6 +1033,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -909,6 +1069,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -927,6 +1101,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -945,6 +1133,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -961,6 +1163,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -995,6 +1205,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1061,6 +1285,14 @@ "guardduty_is_enabled", "rds_instance_multi_az", "s3_bucket_object_versioning" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1285,6 +1517,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1307,6 +1547,20 @@ "guardduty_is_enabled", "redshift_cluster_audit_logging", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1334,6 +1588,20 @@ "guardduty_is_enabled", "redshift_cluster_audit_logging", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1361,6 +1629,20 @@ "guardduty_is_enabled", "redshift_cluster_audit_logging", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1388,6 +1670,20 @@ "guardduty_is_enabled", "redshift_cluster_audit_logging", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1414,6 +1710,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/ffiec_aws.json b/prowler/compliance/aws/ffiec_aws.json index 53f7275a85..8a50b79925 100644 --- a/prowler/compliance/aws/ffiec_aws.json +++ b/prowler/compliance/aws/ffiec_aws.json @@ -37,6 +37,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -74,6 +82,20 @@ "cloudtrail_cloudwatch_logging_enabled", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -148,6 +170,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -166,6 +202,20 @@ "guardduty_is_enabled", "securityhub_enabled", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -183,6 +233,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -237,6 +301,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -254,6 +332,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -367,6 +459,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -386,6 +486,20 @@ "guardduty_is_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -404,6 +518,20 @@ "guardduty_is_enabled", "securityhub_enabled", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -826,6 +954,20 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -871,6 +1013,20 @@ "redshift_cluster_audit_logging", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/gdpr_aws.json b/prowler/compliance/aws/gdpr_aws.json index 930af1db58..a97a11e3dc 100644 --- a/prowler/compliance/aws/gdpr_aws.json +++ b/prowler/compliance/aws/gdpr_aws.json @@ -59,6 +59,14 @@ "cloudwatch_log_metric_filter_security_group_changes", "cloudwatch_log_metric_filter_unauthorized_api_calls", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -85,6 +93,14 @@ "kms_cmk_rotation_enabled", "redshift_cluster_audit_logging", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json b/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json index bcf8f19c3b..871af9e726 100644 --- a/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json +++ b/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json @@ -350,6 +350,20 @@ "cloudtrail_cloudwatch_logging_enabled", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] } ] diff --git a/prowler/compliance/aws/gxp_eu_annex_11_aws.json b/prowler/compliance/aws/gxp_eu_annex_11_aws.json index 1ceb3f817b..fdca6d1747 100644 --- a/prowler/compliance/aws/gxp_eu_annex_11_aws.json +++ b/prowler/compliance/aws/gxp_eu_annex_11_aws.json @@ -19,6 +19,14 @@ "Checks": [ "cloudtrail_multi_region_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -146,6 +154,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -238,6 +254,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -253,6 +277,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/hipaa_aws.json b/prowler/compliance/aws/hipaa_aws.json index 036489d712..9eb243e6cc 100644 --- a/prowler/compliance/aws/hipaa_aws.json +++ b/prowler/compliance/aws/hipaa_aws.json @@ -19,6 +19,20 @@ "Checks": [ "config_recorder_all_regions_enabled", "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -102,6 +116,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -161,6 +189,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -328,6 +370,20 @@ "guardduty_is_enabled", "cloudwatch_log_metric_filter_authentication_failures", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -373,6 +429,20 @@ "cloudwatch_log_metric_filter_authentication_failures", "cloudwatch_log_metric_filter_root_usage", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -402,6 +472,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -514,6 +598,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -649,6 +747,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -756,6 +868,20 @@ "s3_bucket_secure_transport_policy", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/iso27001_2013_aws.json b/prowler/compliance/aws/iso27001_2013_aws.json index 190693d121..1de8c23db8 100644 --- a/prowler/compliance/aws/iso27001_2013_aws.json +++ b/prowler/compliance/aws/iso27001_2013_aws.json @@ -311,6 +311,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -875,6 +883,38 @@ "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" + ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_role_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_access_not_stale_to_sagemaker", + "ConfigKey": "max_unused_sagemaker_access_days", + "Operator": "lte", + "Value": 90 + } ] }, { @@ -1052,6 +1092,20 @@ "Checks": [ "iam_user_accesskey_unused", "iam_user_console_access_unused" + ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90 + } ] }, { @@ -1261,6 +1315,20 @@ "Checks": [ "iam_user_accesskey_unused", "iam_user_console_access_unused" + ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90 + } ] }, { diff --git a/prowler/compliance/aws/iso27001_2022_aws.json b/prowler/compliance/aws/iso27001_2022_aws.json index d47bfcf1d1..563b856317 100644 --- a/prowler/compliance/aws/iso27001_2022_aws.json +++ b/prowler/compliance/aws/iso27001_2022_aws.json @@ -20,6 +20,14 @@ "Checks": [ "securityhub_enabled", "wellarchitected_workload_no_high_or_medium_risks" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -277,6 +285,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -331,6 +347,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -362,6 +386,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -378,6 +410,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -424,6 +464,14 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "guardduty_centrally_managed" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -472,6 +520,14 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "guardduty_centrally_managed" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -490,6 +546,14 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "guardduty_centrally_managed" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1004,6 +1068,14 @@ "organizations_account_part_of_organizations", "accessanalyzer_enabled", "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1080,6 +1152,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1111,6 +1191,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1749,6 +1837,14 @@ "vpc_default_security_group_closed", "vpc_flow_logs_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/kisa_isms_p_2023_aws.json b/prowler/compliance/aws/kisa_isms_p_2023_aws.json index c24b5a23e5..7b0446ac3f 100644 --- a/prowler/compliance/aws/kisa_isms_p_2023_aws.json +++ b/prowler/compliance/aws/kisa_isms_p_2023_aws.json @@ -1211,6 +1211,14 @@ "rds_instance_default_admin", "redshift_cluster_non_default_database_name" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. Control Measures Requirements", @@ -1416,6 +1424,14 @@ "iam_user_administrator_access_policy", "organizations_delegated_administrators" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. Control Measures Requirements", @@ -1486,6 +1502,14 @@ "ssm_documents_set_as_public", "vpc_endpoint_services_allowed_principals_trust_boundaries" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. Control Measures Requirements", @@ -2082,6 +2106,17 @@ "transfer_server_in_transit_encryption_enabled", "workspaces_volume_encryption_enabled" ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } + ], "Attributes": [ { "Domain": "2. Control Measures Requirements", @@ -2819,6 +2854,20 @@ "wafv2_webacl_rule_logging_enabled", "wafv2_webacl_with_rules" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. Control Measures Requirements", @@ -3319,6 +3368,47 @@ "workspaces_volume_encryption_enabled", "workspaces_vpc_2private_1public_subnets_nat" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } + ], "Attributes": [ { "Domain": "2. Control Measures Requirements", @@ -3711,6 +3801,14 @@ "s3_bucket_event_notifications_enabled", "trustedadvisor_errors_and_warnings" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. Control Measures Requirements", @@ -3829,6 +3927,14 @@ "s3_bucket_object_lock", "s3_bucket_object_versioning" ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. Control Measures Requirements", @@ -3866,6 +3972,14 @@ "Checks": [ "drs_job_exist" ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. Control Measures Requirements", diff --git a/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json b/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json index 50e2a149a5..40b338ce41 100644 --- a/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json +++ b/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json @@ -1211,6 +1211,14 @@ "rds_instance_default_admin", "redshift_cluster_non_default_database_name" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. 보호대책 요구사항", @@ -1416,6 +1424,14 @@ "iam_user_administrator_access_policy", "organizations_delegated_administrators" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. 보호대책 요구사항", @@ -1485,6 +1501,14 @@ "ssm_documents_set_as_public", "vpc_endpoint_services_allowed_principals_trust_boundaries" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. 보호대책 요구사항", @@ -2084,6 +2108,17 @@ "transfer_server_in_transit_encryption_enabled", "workspaces_volume_encryption_enabled" ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } + ], "Attributes": [ { "Domain": "2. 보호대책 요구사항", @@ -2822,6 +2857,20 @@ "wafv2_webacl_rule_logging_enabled", "wafv2_webacl_with_rules" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. 보호대책 요구사항", @@ -3322,6 +3371,47 @@ "workspaces_volume_encryption_enabled", "workspaces_vpc_2private_1public_subnets_nat" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } + ], "Attributes": [ { "Domain": "2. 보호대책 요구사항", @@ -3714,6 +3804,14 @@ "s3_bucket_event_notifications_enabled", "trustedadvisor_errors_and_warnings" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. 보호대책 요구사항", @@ -3832,6 +3930,14 @@ "s3_bucket_object_lock", "s3_bucket_object_versioning" ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. 보호대책 요구사항", @@ -3869,6 +3975,14 @@ "Checks": [ "drs_job_exist" ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. 보호대책 요구사항", diff --git a/prowler/compliance/aws/mitre_attack_aws.json b/prowler/compliance/aws/mitre_attack_aws.json index 3d1d5fd378..3ac8cf0432 100644 --- a/prowler/compliance/aws/mitre_attack_aws.json +++ b/prowler/compliance/aws/mitre_attack_aws.json @@ -35,6 +35,32 @@ "awslambda_function_not_publicly_accessible", "ec2_instance_public_ip" ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS CloudEndure Disaster Recovery", @@ -200,6 +226,26 @@ "organizations_scp_check_deny_regions", "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "Amazon GuardDuty", @@ -348,6 +394,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Config", @@ -393,6 +447,26 @@ "guardduty_is_enabled", "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Config", @@ -444,6 +518,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Config", @@ -557,6 +639,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Config", @@ -634,6 +724,26 @@ "inspector2_is_enabled", "inspector2_active_findings_exist" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Config", @@ -821,6 +931,26 @@ "inspector2_is_enabled", "inspector2_active_findings_exist" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Config", @@ -984,6 +1114,14 @@ "cloudfront_distributions_https_enabled", "s3_bucket_secure_transport_policy" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS CloudWatch", @@ -1057,6 +1195,14 @@ "ssm_document_secrets", "secretsmanager_automatic_rotation_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS CloudHSM", @@ -1143,6 +1289,14 @@ "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Network Firewall", @@ -1218,6 +1372,14 @@ "s3_bucket_default_encryption", "rds_instance_storage_encrypted" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Config", @@ -1264,6 +1426,20 @@ "securityhub_enabled", "macie_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Config", @@ -1441,6 +1617,20 @@ "s3_bucket_object_versioning", "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS CloudEndure Disaster Recovery", @@ -1518,6 +1708,20 @@ "efs_have_backup_enabled", "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS CloudEndure Disaster Recovery", @@ -1566,6 +1770,20 @@ "drs_job_exist", "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS CloudEndure Disaster Recovery", @@ -1639,6 +1857,14 @@ "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Shield", @@ -1686,6 +1912,14 @@ "drs_job_exist", "rds_instance_backup_enabled" ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS CloudEndure Disaster Recovery", @@ -1743,6 +1977,20 @@ "cloudwatch_log_metric_filter_sign_in_without_mfa", "cloudwatch_log_metric_filter_unauthorized_api_calls" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS CloudWatch", @@ -1819,6 +2067,20 @@ "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Config", @@ -1910,6 +2172,20 @@ "iam_policy_no_full_access_to_cloudtrail", "iam_policy_no_full_access_to_kms" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Organizations", @@ -1993,6 +2269,14 @@ "Checks": [ "guardduty_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "Amazon GuardDuty", @@ -2071,6 +2355,14 @@ "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS IoT Device Defender", diff --git a/prowler/compliance/aws/nis2_aws.json b/prowler/compliance/aws/nis2_aws.json index d7f193c6c0..3a4d567d25 100644 --- a/prowler/compliance/aws/nis2_aws.json +++ b/prowler/compliance/aws/nis2_aws.json @@ -597,6 +597,14 @@ "accessanalyzer_enabled", "cloudwatch_log_metric_filter_root_usage" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", @@ -1511,6 +1519,17 @@ "Checks": [ "acm_certificates_with_secure_key_algorithms" ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } + ], "Attributes": [ { "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", @@ -1528,6 +1547,17 @@ "route53_domains_privacy_protection_enabled", "iam_no_expired_server_certificates_stored" ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } + ], "Attributes": [ { "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", @@ -1645,6 +1675,14 @@ "efs_access_point_enforce_user_identity", "efs_not_publicly_accessible" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", @@ -1676,6 +1714,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", @@ -1726,6 +1772,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", diff --git a/prowler/compliance/aws/nist_800_171_revision_2_aws.json b/prowler/compliance/aws/nist_800_171_revision_2_aws.json index e5a456bbb3..921bd33a53 100644 --- a/prowler/compliance/aws/nist_800_171_revision_2_aws.json +++ b/prowler/compliance/aws/nist_800_171_revision_2_aws.json @@ -230,6 +230,20 @@ "rds_instance_integration_cloudwatch_logs", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -321,6 +335,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -344,6 +372,14 @@ "guardduty_is_enabled", "rds_instance_integration_cloudwatch_logs", "s3_bucket_server_access_logging_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -383,6 +419,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -400,6 +450,20 @@ "cloudtrail_cloudwatch_logging_enabled", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -687,6 +751,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -715,6 +793,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -732,6 +824,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -749,6 +855,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -772,6 +892,20 @@ "guardduty_is_enabled", "rds_instance_enhanced_monitoring_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -809,6 +943,20 @@ "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", "ec2_networkacl_allow_ingress_any_port" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1028,6 +1176,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1047,6 +1209,20 @@ "securityhub_enabled", "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1064,6 +1240,20 @@ "guardduty_is_enabled", "securityhub_enabled", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1079,6 +1269,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1105,6 +1303,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1131,6 +1343,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] } ] diff --git a/prowler/compliance/aws/nist_800_53_revision_4_aws.json b/prowler/compliance/aws/nist_800_53_revision_4_aws.json index deb2a3cc25..8bd36a3910 100644 --- a/prowler/compliance/aws/nist_800_53_revision_4_aws.json +++ b/prowler/compliance/aws/nist_800_53_revision_4_aws.json @@ -27,6 +27,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -47,6 +61,38 @@ "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" + ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_role_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_access_not_stale_to_sagemaker", + "ConfigKey": "max_unused_sagemaker_access_days", + "Operator": "lte", + "Value": 90 + } ] }, { @@ -73,6 +119,20 @@ "rds_instance_integration_cloudwatch_logs", "redshift_cluster_audit_logging", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -90,6 +150,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -125,6 +199,20 @@ "redshift_cluster_audit_logging", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -270,6 +358,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -399,6 +501,20 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -421,6 +537,20 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -534,6 +664,20 @@ "guardduty_is_enabled", "rds_instance_enhanced_monitoring_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -827,6 +971,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -860,6 +1012,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1110,6 +1276,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1133,6 +1307,20 @@ "ec2_instance_imdsv2_enabled", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1155,6 +1343,20 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1177,6 +1379,20 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1194,6 +1410,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1218,6 +1448,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/nist_800_53_revision_5_aws.json b/prowler/compliance/aws/nist_800_53_revision_5_aws.json index c9eb755e49..e0ef936229 100644 --- a/prowler/compliance/aws/nist_800_53_revision_5_aws.json +++ b/prowler/compliance/aws/nist_800_53_revision_5_aws.json @@ -220,6 +220,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -944,6 +952,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1629,6 +1645,14 @@ "Checks": [ "cloudtrail_multi_region_enabled", "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1828,6 +1852,20 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1906,6 +1944,20 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2290,6 +2342,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2352,6 +2418,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2387,6 +2467,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2466,6 +2560,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2487,6 +2595,20 @@ "guardduty_is_enabled", "rds_instance_enhanced_monitoring_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2522,6 +2644,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2904,6 +3040,14 @@ "guardduty_is_enabled", "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4079,6 +4223,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4095,6 +4247,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4149,6 +4309,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4184,6 +4358,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4199,6 +4387,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4270,6 +4466,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4286,6 +4496,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4303,6 +4521,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4320,6 +4546,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4336,6 +4570,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4353,6 +4595,14 @@ "Checks": [ "guardduty_is_enabled", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4369,6 +4619,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4385,6 +4643,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4401,6 +4667,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4418,6 +4692,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4435,6 +4717,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4516,6 +4806,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4558,6 +4856,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4575,6 +4881,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4591,6 +4905,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4607,6 +4929,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5683,6 +6013,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5850,6 +6188,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5890,6 +6236,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5907,6 +6261,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5924,6 +6286,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5940,6 +6310,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5956,6 +6334,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5988,6 +6374,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6012,6 +6406,14 @@ "rds_instance_integration_cloudwatch_logs", "redshift_cluster_audit_logging", "s3_bucket_server_access_logging_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6028,6 +6430,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6045,6 +6455,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6062,6 +6480,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6078,6 +6504,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6114,6 +6548,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6130,6 +6572,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6197,6 +6647,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6213,6 +6671,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6233,6 +6699,14 @@ "cloudwatch_changes_to_network_route_tables_alarm_configured", "cloudwatch_changes_to_vpcs_alarm_configured", "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6253,6 +6727,14 @@ "cloudwatch_changes_to_network_route_tables_alarm_configured", "cloudwatch_changes_to_vpcs_alarm_configured", "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/nist_csf_1.1_aws.json b/prowler/compliance/aws/nist_csf_1.1_aws.json index a55097e70c..9921efce56 100644 --- a/prowler/compliance/aws/nist_csf_1.1_aws.json +++ b/prowler/compliance/aws/nist_csf_1.1_aws.json @@ -48,6 +48,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -99,6 +113,20 @@ "guardduty_no_high_severity_findings", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -144,6 +172,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -179,6 +221,26 @@ "cloudwatch_log_metric_filter_unauthorized_api_calls", "rds_instance_enhanced_monitoring_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -201,6 +263,20 @@ "guardduty_is_enabled", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -218,6 +294,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -243,6 +333,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -265,6 +369,20 @@ "guardduty_is_enabled", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -291,6 +409,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -316,6 +448,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -349,6 +495,14 @@ "Checks": [ "config_recorder_all_regions_enabled", "ec2_instance_managed_by_ssm" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -454,6 +608,20 @@ "guardduty_is_enabled", "securityhub_enabled", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -471,6 +639,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -488,6 +670,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -523,6 +719,26 @@ "cloudwatch_log_metric_filter_unauthorized_api_calls", "rds_instance_enhanced_monitoring_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -554,6 +770,26 @@ "cloudwatch_log_metric_filter_unauthorized_api_calls", "rds_instance_enhanced_monitoring_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -827,6 +1063,20 @@ "sagemaker_notebook_instance_without_direct_internet_access_configured", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -881,6 +1131,14 @@ "Checks": [ "ec2_instance_managed_by_ssm", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1035,6 +1293,14 @@ "ec2_instance_managed_by_ssm", "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/nist_csf_2.0_aws.json b/prowler/compliance/aws/nist_csf_2.0_aws.json index e890b08573..c06eeec11d 100644 --- a/prowler/compliance/aws/nist_csf_2.0_aws.json +++ b/prowler/compliance/aws/nist_csf_2.0_aws.json @@ -72,6 +72,20 @@ "securityhub_enabled", "wellarchitected_workload_no_high_or_medium_risks", "servicecatalog_portfolio_shared_within_organization_only" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -322,6 +336,26 @@ "wellarchitected_workload_no_high_or_medium_risks", "organizations_delegated_administrators", "organizations_tags_policies_enabled_and_attached" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -352,6 +386,26 @@ "vpc_flow_logs_enabled", "iam_root_mfa_enabled", "iam_root_credentials_management_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -408,6 +462,20 @@ "accessanalyzer_enabled", "guardduty_no_high_severity_findings", "trustedadvisor_errors_and_warnings" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -442,6 +510,26 @@ "organizations_scp_check_deny_regions", "organizations_tags_policies_enabled_and_attached", "organizations_delegated_administrators" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -574,6 +662,14 @@ "opensearch_service_domains_encryption_at_rest_enabled", "redshift_cluster_encrypted_at_rest", "sns_topics_kms_encryption_at_rest_enabled" + ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -610,6 +706,17 @@ "iam_inline_policy_allows_privilege_escalation", "ssm_documents_set_as_public", "s3_bucket_shadow_resource_vulnerability" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -726,6 +833,14 @@ "iam_role_administratoraccess_policy", "iam_policy_no_full_access_to_cloudtrail", "iam_policy_no_full_access_to_kms" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -853,6 +968,14 @@ "iam_customer_unattached_policy_no_administrative_privileges", "accessanalyzer_enabled", "cognito_user_pool_password_policy_symbol" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1224,6 +1347,14 @@ "inspector2_active_findings_exist", "secretsmanager_automatic_rotation_enabled", "secretsmanager_secret_rotated_periodically" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1265,6 +1396,14 @@ "Checks": [ "ssmincidents_enabled_with_plans", "drs_job_exist" + ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1283,6 +1422,14 @@ "inspector2_is_enabled", "guardduty_is_enabled", "inspector2_active_findings_exist" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1329,6 +1476,14 @@ "vpc_flow_logs_enabled", "config_recorder_all_regions_enabled", "config_recorder_using_aws_service_role" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1540,6 +1695,14 @@ "guardduty_is_enabled", "inspector2_is_enabled", "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1662,6 +1825,14 @@ "guardduty_rds_protection_enabled", "guardduty_lambda_protection_enabled", "guardduty_eks_runtime_monitoring_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/pci_3.2.1_aws.json b/prowler/compliance/aws/pci_3.2.1_aws.json index c240548f6a..ca8e968bf9 100644 --- a/prowler/compliance/aws/pci_3.2.1_aws.json +++ b/prowler/compliance/aws/pci_3.2.1_aws.json @@ -628,6 +628,14 @@ "ssm_managed_compliant_patching", "ec2_elastic_ip_unassigned" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "2.4", @@ -643,6 +651,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "2.4.a", @@ -2413,6 +2429,14 @@ "cloudtrail_log_file_validation_enabled", "s3_bucket_cross_region_replication" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "10.5", @@ -2430,6 +2454,14 @@ "s3_bucket_object_versioning", "cloudtrail_log_file_validation_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "10.5.2", @@ -2616,6 +2648,14 @@ "Checks": [ "guardduty_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "11.4", @@ -2631,6 +2671,14 @@ "Checks": [ "guardduty_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "11.4.a", @@ -2646,6 +2694,14 @@ "Checks": [ "guardduty_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "11.4.b", @@ -2661,6 +2717,14 @@ "Checks": [ "guardduty_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "11.4.c", @@ -2676,6 +2740,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "11.5", @@ -2691,6 +2763,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "11.5.a", @@ -2706,6 +2786,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "11.5.b", diff --git a/prowler/compliance/aws/pci_4.0_aws.json b/prowler/compliance/aws/pci_4.0_aws.json index dc8fab9140..e21b543556 100644 --- a/prowler/compliance/aws/pci_4.0_aws.json +++ b/prowler/compliance/aws/pci_4.0_aws.json @@ -4403,6 +4403,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "10.2.1.1: Audit logs are implemented to support the detection of anomalies and suspicious activity, and the forensic analysis of events. ", @@ -9281,6 +9289,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "10.4.1.1: Audit logs are reviewed to identify anomalies or suspicious activity. ", @@ -9363,6 +9379,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "10.4.1: Audit logs are reviewed to identify anomalies or suspicious activity. ", @@ -9459,6 +9483,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "10.4.2: Audit logs are reviewed to identify anomalies or suspicious activity. ", @@ -9551,6 +9583,14 @@ "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled" ], + "ConfigRequirements": [ + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "10.5.1: Audit log history is retained and available for analysis. ", @@ -10179,6 +10219,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "10.6.3: Time-synchronization mechanisms support consistent time settings across all systems. ", @@ -10343,6 +10391,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "10.7.1: Failures of critical security control systems are detected, reported, and responded to promptly. ", @@ -10451,6 +10507,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "10.7.2: Failures of critical security control systems are detected, reported, and responded to promptly. ", @@ -10625,6 +10689,14 @@ "Checks": [ "guardduty_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "11.5.1.1: Network intrusions and unexpected file changes are detected and responded to. ", @@ -10653,6 +10725,14 @@ "Checks": [ "guardduty_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "11.5.1: Network intrusions and unexpected file changes are detected and responded to. ", @@ -11445,6 +11525,14 @@ "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled" ], + "ConfigRequirements": [ + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "3.2.1: Storage of account data is kept to a minimum. ", @@ -11567,6 +11655,14 @@ "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled" ], + "ConfigRequirements": [ + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "3.3.1.1: Sensitive authentication data (SAD) is not stored after authorization. ", @@ -11689,6 +11785,14 @@ "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled" ], + "ConfigRequirements": [ + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "3.3.1.3: Sensitive authentication data (SAD) is not stored after authorization. ", @@ -11811,6 +11915,14 @@ "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled" ], + "ConfigRequirements": [ + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "3.3.2: Sensitive authentication data (SAD) is not stored after authorization. ", @@ -11933,6 +12045,14 @@ "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled" ], + "ConfigRequirements": [ + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "3.3.3: Sensitive authentication data (SAD) is not stored after authorization. ", @@ -13573,6 +13693,17 @@ "Checks": [ "acm_certificates_with_secure_key_algorithms" ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } + ], "Attributes": [ { "Section": "3.7.1: Where cryptography is used to protect stored account data, key management processes and procedures covering all aspects of the key lifecycle are defined and implemented. ", @@ -15001,6 +15132,14 @@ "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled" ], + "ConfigRequirements": [ + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "5.3.4: Anti-malware mechanisms and processes are active, maintained, and monitored. ", @@ -22504,6 +22643,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "A3.3.1: PCI DSS is incorporated into business-as-usual (BAU) activities. ", @@ -23000,6 +23147,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "A3.5.1: Suspicious events are identified and responded to. ", diff --git a/prowler/compliance/aws/prowler_threatscore_aws.json b/prowler/compliance/aws/prowler_threatscore_aws.json index 902beb8abd..c8c093907a 100644 --- a/prowler/compliance/aws/prowler_threatscore_aws.json +++ b/prowler/compliance/aws/prowler_threatscore_aws.json @@ -174,6 +174,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], "Attributes": [ { "Title": "IAM credentials unused disabled", @@ -336,6 +350,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Title": "Access Analyzer enabled", @@ -1541,6 +1563,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Title": "AWS Config is enabled", @@ -1829,6 +1859,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Title": "Security Hub enabled", diff --git a/prowler/compliance/aws/rbi_cyber_security_framework_aws.json b/prowler/compliance/aws/rbi_cyber_security_framework_aws.json index f4e8d1d70e..5de1f5ca8a 100644 --- a/prowler/compliance/aws/rbi_cyber_security_framework_aws.json +++ b/prowler/compliance/aws/rbi_cyber_security_framework_aws.json @@ -185,6 +185,14 @@ "securityhub_enabled", "vpc_flow_logs_enabled", "opensearch_service_domains_audit_logging_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/secnumcloud_3.2_aws.json b/prowler/compliance/aws/secnumcloud_3.2_aws.json index d8736c2d6b..701f931b05 100644 --- a/prowler/compliance/aws/secnumcloud_3.2_aws.json +++ b/prowler/compliance/aws/secnumcloud_3.2_aws.json @@ -202,6 +202,14 @@ "Checks": [ "config_recorder_all_regions_enabled", "ec2_instance_managed_by_ssm" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -323,6 +331,14 @@ "iam_role_administratoraccess_policy", "iam_user_administrator_access_policy", "iam_user_two_active_access_key" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -563,6 +579,17 @@ "Checks": [ "acm_certificates_expiration_check", "acm_certificates_with_secure_key_algorithms" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -735,6 +762,14 @@ "config_recorder_all_regions_enabled", "cloudtrail_multi_region_enabled", "cloudtrail_multi_region_enabled_logging_management_events" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -774,6 +809,14 @@ "guardduty_lambda_protection_enabled", "guardduty_eks_audit_log_enabled", "guardduty_eks_runtime_monitoring_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -911,6 +954,20 @@ "cloudwatch_changes_to_network_gateways_alarm_configured", "cloudwatch_changes_to_network_route_tables_alarm_configured", "cloudwatch_changes_to_vpcs_alarm_configured" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1014,6 +1071,14 @@ "config_recorder_all_regions_enabled", "config_recorder_using_aws_service_role", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1060,6 +1125,14 @@ "Checks": [ "guardduty_is_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1091,6 +1164,14 @@ "Checks": [ "config_recorder_all_regions_enabled", "cloudtrail_multi_region_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1268,6 +1349,20 @@ "guardduty_is_enabled", "securityhub_enabled", "cloudwatch_alarm_actions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1418,6 +1513,14 @@ "Checks": [ "backup_plans_exist", "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1481,6 +1584,20 @@ "Checks": [ "securityhub_enabled", "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1498,6 +1615,14 @@ "Checks": [ "inspector2_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/soc2_aws.json b/prowler/compliance/aws/soc2_aws.json index 5a027d0416..c0041a9dae 100644 --- a/prowler/compliance/aws/soc2_aws.json +++ b/prowler/compliance/aws/soc2_aws.json @@ -43,6 +43,14 @@ "cloudtrail_s3_dataevents_write_enabled", "cloudtrail_multi_region_enabled", "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -61,6 +69,26 @@ "guardduty_is_enabled", "securityhub_enabled", "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -80,6 +108,14 @@ "ssm_managed_compliant_patching", "guardduty_no_high_severity_findings", "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -116,6 +152,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -133,6 +177,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -312,6 +364,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -331,6 +397,20 @@ "securityhub_enabled", "ec2_instance_managed_by_ssm", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -367,6 +447,20 @@ "guardduty_is_enabled", "apigateway_restapi_logging_enabled", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -399,6 +493,20 @@ "cloudwatch_log_group_retention_policy_specific_days_enabled", "vpc_flow_logs_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -426,6 +534,20 @@ "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -463,6 +585,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -600,6 +730,14 @@ "rds_cluster_integration_cloudwatch_logs", "glue_etl_jobs_logging_enabled", "stepfunctions_statemachine_logging_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/azure/c5_azure.json b/prowler/compliance/azure/c5_azure.json index 4ac3b4dd53..6fff7a3691 100644 --- a/prowler/compliance/azure/c5_azure.json +++ b/prowler/compliance/azure/c5_azure.json @@ -2681,6 +2681,17 @@ "app_function_latest_runtime_version", "mysql_flexible_server_minimum_tls_version_12", "sqlserver_recommended_minimal_tls_version" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { @@ -2705,6 +2716,17 @@ "app_function_latest_runtime_version", "mysql_flexible_server_minimum_tls_version_12", "sqlserver_recommended_minimal_tls_version" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { @@ -3903,6 +3925,17 @@ "app_ensure_php_version_is_latest", "storage_ensure_minimum_tls_version_12", "storage_smb_protocol_version_is_latest" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { @@ -4352,6 +4385,17 @@ "sqlserver_recommended_minimal_tls_version", "sqlserver_tde_encrypted_with_cmk", "sqlserver_tde_encryption_enabled" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { @@ -5743,6 +5787,17 @@ "storage_ensure_minimum_tls_version_12", "sqlserver_tde_encrypted_with_cmk", "sqlserver_tde_encryption_enabled" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { @@ -5770,6 +5825,17 @@ "storage_ensure_minimum_tls_version_12", "sqlserver_tde_encrypted_with_cmk", "sqlserver_tde_encryption_enabled" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { @@ -6513,6 +6579,17 @@ "mysql_flexible_server_minimum_tls_version_12", "sqlserver_recommended_minimal_tls_version", "storage_ensure_minimum_tls_version_12" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { diff --git a/prowler/compliance/azure/ccc_azure.json b/prowler/compliance/azure/ccc_azure.json index cb87346d13..661d38302f 100644 --- a/prowler/compliance/azure/ccc_azure.json +++ b/prowler/compliance/azure/ccc_azure.json @@ -56,6 +56,25 @@ "app_ensure_using_http20", "app_ftp_deployment_disabled", "app_function_ftps_deployment_disabled" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + }, + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ] + } ] }, { @@ -726,6 +745,16 @@ ], "Checks": [ "storage_smb_channel_encryption_with_secure_algorithm" + ], + "ConfigRequirements": [ + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ] + } ] }, { diff --git a/prowler/compliance/azure/cis_4.0_azure.json b/prowler/compliance/azure/cis_4.0_azure.json index c075ff4047..5f3e4502f1 100644 --- a/prowler/compliance/azure/cis_4.0_azure.json +++ b/prowler/compliance/azure/cis_4.0_azure.json @@ -253,6 +253,18 @@ "References": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications:https://learn.microsoft.com/en-us/azure/defender-for-cloud/how-to-manage-attack-path:https://learn.microsoft.com/en-us/azure/defender-for-cloud/concept-attack-path", "DefaultValue": "" } + ], + "ConfigRequirements": [ + { + "Check": "defender_attack_path_notifications_properly_configured", + "ConfigKey": "defender_attack_path_minimal_risk_level", + "Operator": "in", + "Value": [ + "Low", + "Medium", + "High" + ] + } ] }, { @@ -375,6 +387,16 @@ "Checks": [ "storage_smb_channel_encryption_with_secure_algorithm" ], + "ConfigRequirements": [ + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ] + } + ], "Attributes": [ { "Section": "10 Storage Services", diff --git a/prowler/compliance/azure/cis_5.0_azure.json b/prowler/compliance/azure/cis_5.0_azure.json index 8ea6f50d7c..71e43aeee9 100644 --- a/prowler/compliance/azure/cis_5.0_azure.json +++ b/prowler/compliance/azure/cis_5.0_azure.json @@ -2614,6 +2614,18 @@ "References": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications:https://learn.microsoft.com/en-us/azure/defender-for-cloud/how-to-manage-attack-path:https://learn.microsoft.com/en-us/azure/defender-for-cloud/concept-attack-path", "DefaultValue": "" } + ], + "ConfigRequirements": [ + { + "Check": "defender_attack_path_notifications_properly_configured", + "ConfigKey": "defender_attack_path_minimal_risk_level", + "Operator": "in", + "Value": [ + "Low", + "Medium", + "High" + ] + } ] }, { @@ -3006,6 +3018,16 @@ "Checks": [ "storage_smb_channel_encryption_with_secure_algorithm" ], + "ConfigRequirements": [ + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ] + } + ], "Attributes": [ { "Section": "9 Storage Services", diff --git a/prowler/compliance/azure/cis_6.0_azure.json b/prowler/compliance/azure/cis_6.0_azure.json new file mode 100644 index 0000000000..625a0c18fa --- /dev/null +++ b/prowler/compliance/azure/cis_6.0_azure.json @@ -0,0 +1,2863 @@ +{ + "Framework": "CIS", + "Name": "CIS Microsoft Azure Foundations Benchmark v6.0.0", + "Version": "6.0", + "Provider": "Azure", + "Description": "The CIS Azure Foundations Benchmark provides prescriptive guidance for configuring security options for a subset of Azure with an emphasis on foundational, testable, and architecture agnostic settings.", + "Requirements": [ + { + "Id": "2.1.1", + "Description": "Ensure that Azure Databricks is deployed in a customer-managed virtual network (VNet)", + "Checks": [ + "databricks_workspace_vnet_injection_enabled" + ], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Azure Databricks is deployed in a customer-managed virtual network (VNet)", + "RationaleStatement": "Using a customer-managed VNet ensures better control over network security and aligns with zero-trust architecture principles. It allows for: - Restricted outbound internet access to prevent unauthorized data exfiltration. - Integration with on-premises networks via VPN or ExpressRoute for hybrid connectivity. - Fine-grained NSG policies to restrict access at the subnet level. - Private Link for secure API access, avoiding public internet exposure.", + "ImpactStatement": "- Requires additional configuration during Databricks workspace deployment. - Might increase operational overhead for network maintenance. - May impact connectivity if misconfigured (e.g., restrictive NSG rules or missing routes).", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Delete the existing Databricks workspace (migration required). 1. Create a new Databricks workspace with VNet Injection: 1. Go to Azure Portal Create Databricks Workspace. 1. Select Advanced Networking. 1. Choose Deploy into your own Virtual Network. 1. Specify a customer-managed VNet and associated subnets. 1. Enable Private Link for secure API access. **Remediate from Azure CLI** Deploy a new Databricks workspace in a custom VNet: ``` az databricks workspace create --name \\ --resource-group \\ --location \\ --managed-resource-group \\ --enable-no-public-ip true \\ --network-security-group-rule NoAzureServices \\ --public-network-access Disabled \\ --custom-virtual-network-id /subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks/ ``` Ensure NSG Rules are correctly configured: ``` az network nsg rule create --resource-group \\ --nsg-name \\ --name DenyAllOutbound \\ --direction Outbound \\ --access Deny \\ --priority 4096 ``` **Remediate from PowerShell** ``` New-AzDatabricksWorkspace -ResourceGroupName -Name -Location -ManagedResourceGroupName -CustomVirtualNetworkId /subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks/ ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to Azure Portal Search for Databricks Workspaces. 1. Select the Databricks Workspace to audit. 1. Under Networking, check if the workspace is deployed in a Customer-Managed VNet. 1. If the Virtual Network field shows Databricks-Managed VNet, it is non-compliant. 1. Verify NSG rules and Private Endpoints for fine-grained access control. **Audit from Azure CLI** Run the following command to check if Databricks is using a customer-managed VNet: ``` az network vnet show --resource-group --name ``` Ensure that Databricks subnets are present in the VNet configuration. Validate NSG rules attached to the Databricks subnets. **Audit from PowerShell** ``` Get-AzDatabricksWorkspace -ResourceGroupName -Name | Select-Object VirtualNetworkId ``` If VirtualNetworkId is null or shows a Databricks-Managed VNet, it is non-compliant. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [9c25c9e4-ee12-4882-afd2-11fb9d87893f](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%9c25c9e4-ee12-4882-afd2-11fb9d87893f) **- Name:** 'Azure Databricks Workspaces should be in a virtual network'", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, Azure Databricks uses a Databricks-Managed VNet." + } + ] + }, + { + "Id": "2.1.2", + "Description": "Ensure that Network Security Groups are Configured for Databricks Subnets", + "Checks": [], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Network Security Groups are Configured for Databricks Subnets", + "RationaleStatement": "Using NSGs with both explicit allow and deny rules provides clear documentation and control over permitted and prohibited traffic. While Azure NSGs implicitly deny all traffic not explicitly allowed, defining explicit deny rules for known malicious or unnecessary sources enhances clarity, simplifies troubleshooting, and supports compliance audits.", + "ImpactStatement": "* NSGs require periodic maintenance to ensure rule accuracy. * Misconfigured NSGs could inadvertently block required traffic.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Assign NSG to Databricks subnets under Networking > NSG Settings.", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to Virtual Networks > Subnets, and review NSG assignments. **Audit from Azure CLI** ``` az network nsg list --query [].{Name:name, Rules:securityRules} ``` **Audit from PowerShell** ``` Get-AzNetworkSecurityGroup -ResourceGroupName ```", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/security/benchmark/azure/baselines/azure-databricks-security-baseline:https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/vnet-inject#network-security-group-rules", + "DefaultValue": "By default, Databricks subnets do not have NSGs assigned." + } + ] + }, + { + "Id": "2.1.3", + "Description": "Ensure that Traffic is Encrypted Between Cluster Worker Nodes", + "Checks": [], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that Traffic is Encrypted Between Cluster Worker Nodes", + "RationaleStatement": "* Protects sensitive data during transit between cluster nodes, mitigating risks of data interception or unauthorized access. * Aligns with organizational security policies and compliance requirements that mandate encryption of data in transit. * Enhances overall security posture by ensuring that all inter-node communications within the cluster are encrypted.", + "ImpactStatement": "* Enabling encryption may introduce a performance penalty due to the computational overhead associated with encrypting and decrypting traffic. This can result in longer query execution times, especially for data-intensive operations. * Implementing encryption requires creating and managing init scripts, which adds complexity to cluster configuration and maintenance. * The shared encryption secret is derived from the hash of the keystore stored in DBFS. If the keystore is updated or rotated, all running clusters must be restarted to prevent authentication failures between Spark workers and drivers.", + "RemediationProcedure": "Create a JKS keystore: 1. Generate a Java KeyStore (JKS) file that will be used for SSL/TLS encryption. 2. Upload the keystore file to a secure directory in DBFS (e.g. /dbfs//jetty_ssl_driver_keystore.jks). Develop an init script: 3. Create an init script that performs the following tasks: - Retrieves the JKS keystore file and password. - Derives a shared encryption secret from the keystore. - Configures Spark driver and executor settings to enable encryption. 4. Example init script: ``` #!/bin/bash set -euo pipefail keystore_dbfs_file=/dbfs//jetty_ssl_driver_keystore.jks max_attempts=30 while [ ! -f ${keystore_dbfs_file} ]; do if [ $max_attempts == 0 ]; then echo ERROR: Unable to find the file : $keystore_dbfs_file. Failing the script. exit 1 fi sleep 2s ((max_attempts--)) done sasl_secret=$(sha256sum $keystore_dbfs_file | cut -d' ' -f1) if [ -z ${sasl_secret} ]; then echo ERROR: Unable to derive the secret. Failing the script. exit 1 fi local_keystore_file=$DB_HOME/keys/jetty_ssl_driver_keystore.jks local_keystore_password=gb1gQqZ9ZIHS if [[ $DB_IS_DRIVER = TRUE ]]; then driver_conf=${DB_HOME}/driver/conf/spark-branch.conf echo Configuring driver conf at $driver_conf if [ ! -e $driver_conf ]; then echo spark.authenticate true >> $driver_conf echo spark.authenticate.secret $sasl_secret >> $driver_conf echo spark.authenticate.enableSaslEncryption true >> $driver_conf echo spark.network.crypto.enabled true >> $driver_conf echo spark.network.crypto.keyLength 256 >> $driver_conf echo spark.network.crypto.keyFactoryAlgorithm PBKDF2WithHmacSHA1 >> $driver_conf echo spark.io.encryption.enabled true >> $driver_conf echo spark.ssl.enabled true >> $driver_conf echo spark.ssl.keyPassword $local_keystore_password >> $driver_conf echo spark.ssl.keyStore $local_keystore_file >> $driver_conf echo spark.ssl.keyStorePassword $local_keystore_password >> $driver_conf echo spark.ssl.protocol TLSv1.3 >> $driver_conf fi fi executor_conf=${DB_HOME}/conf/spark.executor.extraJavaOptions echo Configuring executor conf at $executor_conf if [ ! -e $executor_conf ]; then echo -Dspark.authenticate=true >> $executor_conf echo -Dspark.authenticate.secret=$sasl_secret >> $executor_conf echo -Dspark.authenticate.enableSaslEncryption=true >> $executor_conf echo -Dspark.network.crypto.enabled=true >> $executor_conf echo -Dspark.network.crypto.keyLength=256 >> $executor_conf echo -Dspark.network.crypto.keyFactoryAlgorithm=PBKDF2WithHmacSHA1 >> $executor_conf echo -Dspark.io.encryption.enabled=true >> $executor_conf echo -Dspark.ssl.enabled=true >> $executor_conf echo -Dspark.ssl.keyPassword=$local_keystore_password >> $executor_conf echo -Dspark.ssl.keyStore=$local_keystore_file >> $executor_conf echo -Dspark.ssl.keyStorePassword=$local_keystore_password >> $executor_conf echo -Dspark.ssl.protocol=TLSv1.3 >> $executor_conf fi ``` 5. Save.", + "AuditProcedure": "**Audit from Azure Portal** Review cluster init scripts: 1. Navigate to your Azure Databricks workspace, go to the Clusters section, select a cluster, and check the Advanced Options for any init scripts that configure encryption settings. Verify spark configuration: 2. Ensure that the following Spark configurations are set: ``` spark.authenticate true spark.authenticate.enableSaslEncryption true spark.network.crypto.enabled true spark.network.crypto.keyLength 256 spark.network.crypto.keyFactoryAlgorithm PBKDF2WithHmacSHA1 spark.io.encryption.enabled true ``` These settings can be found in the cluster's Spark configuration properties. Check keystone management: 3. Verify that the Java KeyStore (JKS) file is securely stored in DBFS and that its integrity is maintained. 4. Ensure that the keystore password is securely managed and not hardcoded in scripts.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/databricks/security/keys/encrypt-otw", + "DefaultValue": "By default, traffic is not encrypted between cluster worker nodes." + } + ] + }, + { + "Id": "2.1.4", + "Description": "Ensure that Users and Groups are Synced from Microsoft Entra ID to Azure Databricks", + "Checks": [], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that Users and Groups are Synced from Microsoft Entra ID to Azure Databricks", + "RationaleStatement": "Syncing users and groups from Microsoft Entra ID centralizes access control, enforces the least privilege principle by automatically revoking unnecessary access, reduces administrative overhead by eliminating manual user management, and ensures auditability and compliance with industry regulations.", + "ImpactStatement": "SCIM provisioning requires role mapping to avoid misconfigured user privileges.", + "RemediationProcedure": "**Remediate from Azure Portal** Enable provisioning in Azure Portal: 1. Go to `Microsoft Entra ID`. 1. Under `Manage`, click `Enterprise applications`. 1. Click the name of the Azure Databricks SCIM application. 1. Under `Provisioning`, select `Automatic` and enter the SCIM endpoint and API token from Databricks. Enable provisioning in Databricks: 5. Navigate to `Admin Console` > `Identity and Access Management`. 6. Enable SCIM provisioning and generate an API token. Configure role assignments: 7. Ensure groups from Entra ID are mapped to appropriate Databricks roles. 8. Restrict administrative privileges to designated security groups. Regularly monitor sync logs: 9. Periodically review sync logs in Microsoft Entra ID and Databricks Admin Console. 10. Configure Azure Monitor alerts for provisioning failures. Disable manual user creation in Databricks: 11. Ensure that all user management is controlled via SCIM sync from Entra ID. 12. Disable personal access token usage for authentication. **Remediate from Azure CLI** Enable SCIM User and Group Provisioning in Azure Databricks: ``` az ad app update --id --set provisioning.provisioningMode=Automatic ```", + "AuditProcedure": "**Audit from Azure Portal** Verify SCIM provisioning is enabled: 1. Go to `Microsoft Entra ID`. 1. Under `Manage`, click `Enterprise applications`. 1. Click the name of the Azure Databricks SCIM application. 1. Under `Provisioning`, confirm that SCIM provisioning is enabled and running. Check user sync status in Azure Portal: 5. Under `Provisioning Logs`, verify the last successful sync and any failed entries. Check user sync status in Databricks: 6. Go to `Admin Console` > `Identity and Access Management`. 7. Confirm that Users and Groups match those assigned in Microsoft Entra ID. Ensure role-based access control (RBAC) mapping is correct: 8. Verify that users are assigned appropriate Databricks roles (e.g. Admin, User, Contributor). 9. Confirm that groups are mapped to workspace access roles.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/databricks/administration-guide/users-groups/scim/aad", + "DefaultValue": "By default, Azure Databricks does not sync users and groups from Microsoft Entra ID." + } + ] + }, + { + "Id": "2.1.5", + "Description": "Ensure that Unity Catalog is Configured for Azure Databricks", + "Checks": [], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that Unity Catalog is Configured for Azure Databricks", + "RationaleStatement": "* Enforces centralized access control policies and reduces data security risks. * Enables identity-based authentication via Microsoft Entra ID. * Improves compliance with industry regulations (e.g. GDPR, HIPAA, SOC 2) by providing audit logs and access visibility. * Prevents unauthorized data access through table-, row-, and column-level security (RLS & CLS).", + "ImpactStatement": "* Improperly configured permissions may lead to data exfiltration or unauthorized access. * Unity Catalog requires structured governance policies to be effective and prevent overly permissive access.", + "RemediationProcedure": "Use the remediation procedure written in this article: https://learn.microsoft.com/en-us/azure/databricks/data-governance/unity-catalog/get-started.", + "AuditProcedure": "Method 1: Verify unity catalog deployment: 1. As an Azure Databricks account admin, log into the account console. 1. Click Workspaces. 1. Find your workspace and check the Metastore column. If a metastore name is present, your workspace is attached to a Unity Catalog metastore and therefore enabled for Unity Catalog. Method 2: Run a SQL query to confirm Unity Catalog enablement Run the following SQL query in the SQL query editor or a notebook that is attached to a Unity Catalog-enabled compute resource. No admin role is required. ``` SELECT CURRENT_METASTORE(); ``` If the query returns a metastore ID like the following, then your workspace is attached to a Unity Catalog metastore and therefore enabled for Unity Catalog.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/databricks/data-governance/unity-catalog/:https://learn.microsoft.com/en-us/azure/databricks/admin/users-groups/:https://learn.microsoft.com/en-us/azure/databricks/data-governance/unity-catalog/enable-workspaces", + "DefaultValue": "New workspaces have Unity Catalog enabled by default. Existing workspaces may require manual enablement." + } + ] + }, + { + "Id": "2.1.6", + "Description": "Ensure that Usage is Restricted and Expiry is Enforced for Databricks Personal Access Tokens", + "Checks": [], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that Usage is Restricted and Expiry is Enforced for Databricks Personal Access Tokens", + "RationaleStatement": "Restricting usage and enforcing expiry for personal access tokens reduces exposure to long-lived tokens, minimizes the risk of API abuse if compromised, and aligns with security best practices through controlled issuance and enforced expiry.", + "ImpactStatement": "If revoked improperly, applications relying on these tokens may fail, requiring a remediation plan for token rotation. Increased administrative effort is required to track and manage API tokens effectively.", + "RemediationProcedure": "**Remediate from Azure Portal** Disable personal access tokens: If your workspace does not require PATs, you can disable them entirely to prevent their use.", + "AuditProcedure": "Azure Databricks administrators can monitor and revoke personal access tokens within their workspace. Detailed instructions are available in the Monitor and Revoke Personal Access Tokens section of the Microsoft documentation: https://learn.microsoft.com/en-us/azure/databricks/admin/access-control/tokens. To evaluate the usage of personal access tokens in your Azure Databricks account, you can utilize the provided notebook that lists all PATs not rotated or updated in the last 90 days, allowing you to identify tokens that may require revocation. This process is detailed here: https://docs.azure.cn/en-us/databricks/security/auth/oauth-pat-usage. Implementing diagnostic logging provides a comprehensive reference of audit log services and events, enabling you to track activities related to personal access tokens. More information can be found in the diagnostic log reference section: https://docs.azure.cn/en-us/databricks/security/auth/oauth-pat-usage.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/databricks/administration-guide/access-control/tokens:https://learn.microsoft.com/en-us/azure/databricks/dev-tools/auth/", + "DefaultValue": "By default, personal access tokens are enabled and users can create the Personal access token and their expiry time." + } + ] + }, + { + "Id": "2.1.7", + "Description": "Ensure that Diagnostic Log Delivery is Configured for Azure Databricks", + "Checks": [], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Diagnostic Log Delivery is Configured for Azure Databricks", + "RationaleStatement": "Diagnostic logging provides visibility into security and operational activities within Databricks workspaces while maintaining an audit trail for forensic investigations, and it supports compliance with regulatory standards that require logging and monitoring.", + "ImpactStatement": "Logs consume storage and may require additional monitoring tools, leading to increased operational overhead and costs. Incomplete log configurations may result in missing critical events, reducing monitoring effectiveness.", + "RemediationProcedure": "**Remediate from Azure Portal** Enable diagnostic logging for Azure Databricks: 1. Navigate to your Azure Databricks workspace. 1. In the left-hand menu, select `Monitoring` > `Diagnostic settings`. 1. Click `+ Add diagnostic setting`. 1. Under `Category details`, select the log categories you wish to capture, such as AuditLogs, Clusters, Notebooks, and Jobs. 1. Choose a destination for the logs: - `Log Analytics workspace`: For advanced querying and monitoring. - `Storage account`: For long-term retention. - `Event Hub`: For integration with third-party systems. 1. Provide a `Name` for the diagnostic setting. 1. Click `Save`. Implement log retention policies: 1. Navigate to your Log Analytics workspace. 1. Under `General`, select `Usage and estimated costs`. 1. Click `Data Retention`. 1. Adjust the retention period slider to the desired number of days (up to 730 days). 1. Click `OK`. Monitor logs for anomalies: 1. Navigate to `Azure Monitor`. 1. Select `Alerts` > `+ New alert rule`. 1. Under `Scope`, specify the Databricks resource. 1. Define `Condition` based on log queries that identify anomalies (e.g. unauthorized access attempts). 1. Configure `Actions` to notify stakeholders or trigger automated responses. 1. Provide an Alert rule `name` and `description`. 1. Click `Create alert rule`. **Remediate from Azure CLI** Enable diagnostic logging for Azure Databricks: ``` az monitor diagnostic-settings create --name DatabricksLogging --resource --logs '[{category: AuditLogs, enabled: true}, {category: Clusters, enabled: true}, {category: Notebooks, enabled: true}, {category: Jobs, enabled: true}]' --workspace ``` Implement log retention policies: ``` az monitor log-analytics workspace update --resource-group --name --retention-time 365 ``` Monitor logs for anomalies: ``` az monitor activity-log alert create --name DatabricksAnomalyAlert --resource-group --scopes --condition contains 'UnauthorizedAccess' ```", + "AuditProcedure": "**Audit from Azure Portal** Check if diagnostic logging is enabled for the Databricks workspace: 1. Go to `Azure Databricks`. 1. Select a workspace. 1. In the left-hand menu, select `Monitoring` > `Diagnostic settings`. 1. Verify if a diagnostic setting is configured. If not, diagnostic logging is not enabled. Ensure that logging is enabled for the following categories:", + "AdditionalInformation": "* Ensure that the Azure Databricks workspace is on the Premium plan to utilize diagnostic logging features. * Regularly review and update alert rules to adapt to evolving security threats and operational requirements.", + "References": "https://learn.microsoft.com/en-us/azure/databricks/admin/account-settings/audit-log-delivery:https://learn.microsoft.com/en-us/troubleshoot/azure/azure-monitor/log-analytics/billing/configure-data-retention", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.8", + "Description": "Ensure Critical Data in Azure Databricks is Encrypted with Customer-managed Keys (CMK)", + "Checks": [ + "databricks_workspace_cmk_encryption_enabled" + ], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure Critical Data in Azure Databricks is Encrypted with Customer-managed Keys (CMK)", + "RationaleStatement": "By default in Azure, data at rest tends to be encrypted using Microsoft Managed Keys. If your organization wants to control and manage encryption keys for compliance and defense-in-depth, Customer Managed Keys can be established. While it is possible to automate the assessment of this recommendation, the assessment status for this recommendation remains 'Manual' due to ideally limited scope. The scope of application - which workloads CMK is applied to - should be carefully considered to account for organizational capacity and targeted to workloads with specific need for CMK.", + "ImpactStatement": "If the key expires due to setting the 'activation date' and 'expiration date', the key must be rotated manually. Using Customer Managed Keys may also incur additional man-hour requirements to create, store, manage, and protect the keys as needed.", + "RemediationProcedure": "NOTE: These remediations assume that an Azure KeyVault already exists in the subscription. Remediate from Azure CLI 1. Create a dedicated key: az keyvault key create --vault-name --name -protection 2. Assign permissions to Databricks: az keyvault set-policy --name --resource-group --spn --key-permissions get wrapKey unwrapKey 3. Enable encryption with CMK: az databricks workspace update --name --resourcegroup --key-source Microsoft.KeyVault --key-name --keyvault-uri Remediate from PowerShell $Key = Add-AzKeyVaultKey -VaultName -Name Destination Set-AzDatabricksWorkspace -ResourceGroupName WorkspaceName -EncryptionKeySource Microsoft.KeyVault -KeyVaultUri $Key.Id", + "AuditProcedure": "Audit: Audit from Azure Portal 1. Go to Azure Portal → Databricks Workspaces. 2. Select a Databricks Workspace and go to Encryption settings. 3. Check if customer-managed keys (CMK) are enabled under Managed Disk Encryption .4. If CMK is not enabled, the workspace is non-compliant. Audit from Azure CLI Run the following command to check encryption settings for Databricks workspace: az databricks workspace show --name --resourcegroup --query encryption Ensure that keySource is set to Microsoft.KeyVault. Audit from PowerShell Get-AzDatabricksWorkspace -ResourceGroupName -Name | Select-Object Encryption Verify that encryption is set to Customer-Managed Keys (CMK). Audit from Databricks CLI databricks workspace get-metadata --workspace-id Ensure that encryption settings reflect a CMK setup.", + "AdditionalInformation": "This recommendation is based on the Common Reference Recommendation Ensure critical data is encrypted with customer-managed keys (CMK).", + "References": "https://docs.microsoft.com/en-us/azure/security/fundamentals/data-encryption-best-practices#protect-data-at-rest:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-5-use-customer-managed-key-option-in-data-at-rest-encryption-when-required", + "DefaultValue": "By default, Encryption type is set to Microsoft Managed Keys." + } + ] + }, + { + "Id": "2.1.9", + "Description": "Ensure 'No Public IP' is Set to 'Enabled'", + "Checks": [ + "databricks_workspace_no_public_ip_enabled" + ], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure 'No Public IP' is Set to 'Enabled'", + "RationaleStatement": "Enabling secure cluster connectivity limits exposure to the public internet, improving security and reducing the risk of external attacks.", + "ImpactStatement": "Enabling secure cluster connectivity requires careful network configuration. Before secure cluster connectivity can be enabled, Azure Databricks workspaces must be deployed in a customer-managed virtual network (VNet injection).", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Azure Databricks`. 2. Click the name of a workspace. 3. Under `Settings`, click `Networking`. 4. Under `Network access`, next to `Deploy Azure Databricks workspace with Secure Cluster Connectivity (No Public IP)`, click the radio button next to `Enabled`. 5. Click `Save`. 6. Repeat steps 1-5 for each workspace requiring remediation. **Remediate from Azure CLI** For each workspace requiring remediation, run the following command to set enableNoPublicIp to true: ``` az databricks workspace update --resource-group --name --enable-no-public-ip true ``` **Remediate from PowerShell** For each workspace requiring remediation, run the following command to set EnableNoPublicIP to True: ``` Update-AzDatabricksWorkspace -ResourceGroupName -Name -EnableNoPublicIP ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Azure Databricks`. 2. Click the name of a workspace. 3. Under `Settings`, click `Networking`. 4. Under `Network access`, ensure that `Deploy Azure Databricks workspace with Secure Cluster Connectivity (No Public IP)` is set to `Enabled`. 5. Repeat steps 1-4 for each workspace. **Audit from Azure CLI** Run the following command to list workspaces: ``` az databricks workspace list ``` For each workspace, run the following command to get the enableNoPublicIp setting: ``` az databricks workspace show --resource-group --name --query parameters.enableNoPublicIp.value ``` Ensure that `true` is returned. **Audit from PowerShell** Run the following command to list workspaces: ``` Get-AzDatabricksWorkspace ``` Run the following command to get the workspace in a resource group with a given name: ``` $workspace = Get-AzDatabricksWorkspace -ResourceGroupName -Name ``` Run the following command to get the EnableNoPublicIp setting: ``` $workspace.EnableNoPublicIP ``` Ensure that `True` is returned. **Audit from Azure Policy** - **Policy ID:** [51c1490f-3319-459c-bbbc-7f391bbed753] **- Name:** 'Azure Databricks Clusters should disable public IP'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/secure-cluster-connectivity:https://learn.microsoft.com/en-us/cli/azure/databricks/workspace:https://learn.microsoft.com/en-us/powershell/module/az.databricks", + "DefaultValue": "No Public IP is set to Enabled by default." + } + ] + }, + { + "Id": "2.1.10", + "Description": "Ensure 'Allow Public Network Access' is set to 'Disabled'", + "Checks": [ + "databricks_workspace_public_network_access_disabled" + ], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure 'Allow Public Network Access' is set to 'Disabled'", + "RationaleStatement": "Disabling public network access improves security by ensuring that Azure Databricks workspaces are not exposed on the public internet.", + "ImpactStatement": "Prior to disabling public network access, it is strongly recommended that virtual network integration is completed or private endpoints/links are set up. Disabling public network access restricts access to the service and will require the configuration of a virtual network and/or private endpoints for any services or users needing access within trusted networks.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Azure Databricks`. 2. Click the name of a workspace. 3. Under `Settings` click `Networking`. 4. Under `Network access`, next to `Allow Public Network Access`, click the radio button next to `Disabled`. 5. Click `Save`. 6. Repeat steps 1-5 for each workspace requiring remediation. **Remediate from Azure CLI** For each workspace requiring remediation, run the following command to set publicNetworkAccess to Disabled: ``` az databricks workspace update --resource-group --name --public-network-access Disabled ``` **Remediate from PowerShell** For each workspace requiring remediation, run the following command to set PublicNetworkAccess to Disabled: ``` Update-AzDatabricksWorkspace -ResourceGroupName -Name -PublicNetworkAccess Disabled ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Azure Databricks`. 2. Click the name of a workspace. 3. Under `Settings` click `Networking`. 4. Under `Network access`, ensure `Allow Public Network Access` is set to `Disabled`. 5. Repeat steps 1-4 for each workspace. **Audit from Azure CLI** Run the following command to list workspaces: ``` az databricks workspace list ``` For each workspace, run the following command to get the publicNetworkAccess setting: ``` az databricks workspace show --resource-group --name --query publicNetworkAccess ``` Ensure that `Disabled` is returned. **Audit from PowerShell** Run the following command to list workspaces: ``` Get-AzDatabricksWorkspace ``` Run the following command to get the PublicNetworkAccess setting: ``` $workspace = Get-AzDatabricksWorkspace -ResourceGroupName -Name $workspace.PublicNetworkAccess ``` Ensure that `Disabled` is returned. **Audit from Azure Policy** - **Policy ID:** [0e7849de-b939-4c50-ab48-fc6b0f5eeba2] **- Name:** 'Azure Databricks Workspaces should disable public network access'", + "AdditionalInformation": "This recommendation is based on the Common Reference Recommendation Ensure public network access is Disabled.", + "References": "https://learn.microsoft.com/en-us/cli/azure/databricks/workspace:https://learn.microsoft.com/en-us/powershell/module/az.databricks", + "DefaultValue": "Allow Public Network Access is set to Enabled by default." + } + ] + }, + { + "Id": "2.1.11", + "Description": "Ensure Private Endpoints are used to access Azure Databricks workspaces", + "Checks": [], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Private Endpoints are used to access Azure Databricks workspaces", + "RationaleStatement": "Using private endpoints for Azure Databricks workspaces ensures that all communication between clients, services, and data sources occurs over a secure, private IP space within an Azure Virtual Network (VNet). This approach eliminates exposure to the public internet, significantly reducing the attack surface and aligning with Zero Trust principles.", + "ImpactStatement": "If an Azure Virtual Network is not implemented correctly, this may result in the loss of critical network traffic. Private endpoints are charged per hour of use. Before a private endpoint can be configured, Azure Databricks workspaces must be deployed in a customer-managed virtual network, must have secure cluster connectivity enabled, and must be on the Premium pricing tier.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Azure Databricks`. 2. Click the name of a workspace. 3. Under `Settings`, click `Networking`. 4. Click `Private endpoint connections`. 5. Click `+ Private endpoint`. 6. Under `Project details`, select a Subscription and a Resource group. 7. Under `Instance details`, provide a Name, Network Interface Name, and select a Region. 8. Click `Next : Resource >`. 9. Select a Target sub-resource. 10. Click `Next : Virtual Network >`. 11. Under `Networking`, select a Virtual network and a Subnet. 12. Optionally, configure Private IP configuration and Application security group. 13. Click `Next : DNS >`. 14. Optionally, configure Private DNS integration. 15. Click `Next : Tags >`. 16. Optionally, configure tags. 17. Click `Next : Review + create >`. 18. Click `Create`. 19. Repeat steps 1-18 for each workspace requiring remediation. **Remediate from Azure CLI** For each workspace requiring remediation, run the following command to create a private endpoint connection: ``` az network private-endpoint create --resource-group --name --location --vnet-name --subnet --private-connection-resource-id --connection-name --group-id ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Azure Databricks`. 2. Click the name of a workspace. 3. Under `Settings`, click `Networking`. 4. Click `Private endpoint connections`. 5. Ensure a private endpoint connection exists with a connection state of `Approved`. 6. Repeat steps 1-5 for each workspace. **Audit from Azure CLI** Run the following command to list workspaces: ``` az databricks workspace list ``` For each workspace, run the following command to get the privateEndpointConnections configuration: ``` az databricks workspace show --resource-group --name --query privateEndpointConnections ``` Ensure a private endpoint connection is returned with a privateLinkServiceConnectionState status of `Approved`. **Audit from PowerShell** Run the following command to list workspaces: ``` Get-AzDatabricksWorkspace ``` Run the following command to get the PrivateEndpointConnection configuration: ``` $workspace = Get-AzDatabricksWorkspace -ResourceGroupName -Name $workspace.PrivateEndpointConnection | Select-Object -Property Id,PrivateLinkServiceConnectionStateStatus ``` Ensure a private endpoint connection is returned with a PrivateLinkServiceConnectionStateStatus of `Approved`. **Audit from Azure Policy** - **Policy ID:** [258823f2-4595-4b52-b333-cc96192710d8] **- Name:** 'Azure Databricks Workspaces should use private link'", + "AdditionalInformation": "This recommendation is based on the Common Reference Recommendation Ensure Private Endpoints are used to access {service}.", + "References": "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/private-link:https://learn.microsoft.com/en-us/cli/azure/databricks/workspace:https://learn.microsoft.com/en-us/powershell/module/az.databricks", + "DefaultValue": "Private endpoints are not configured for Azure Databricks workspaces by default." + } + ] + }, + { + "Id": "2.1.12", + "Description": "Ensure Azure Databricks groups are reviewed periodically", + "Checks": [], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure Azure Databricks groups are reviewed periodically", + "RationaleStatement": "To ensure accurate privileges for your Azure Databricks implementation, your organization should review all users and permission assignments on a regular interval.", + "ImpactStatement": "Administrative overhead of user management.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select `Azure Databricks`. 1. Select the Databricks implementation you wish to audit. 1. Select `Access control (IAM)`. 1. Scroll down and select `Add role assignment`. 1. Search for the role you wish to add. Then select `Next`. 1. Select the group members you wish to add. Then select `Next`. 1. Review the info you have chosen, then select `Review + assign`.", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select `Azure Databricks`. 1. Select the Databricks implementation you wish to audit. 1. Select `Access control (IAM)`. 1. In the horizontal menu select `Role assignments`. 1. Audit the list for each role and its assignment to each user.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/security/benchmark/azure/baselines/azure-databricks-security-baseline:https://learn.microsoft.com/en-us/azure/databricks/security/auth/", + "DefaultValue": "By default Azure Databricks only has the Owner user and role assigned." + } + ] + }, + { + "Id": "3.1.1", + "Description": "Ensure only MFA Enabled Identities can Access Privileged Virtual Machine", + "Checks": [ + "entra_user_with_vm_access_has_mfa" + ], + "Attributes": [ + { + "Section": "3 Compute Services", + "SubSection": "3.1 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure only MFA Enabled Identities can Access Privileged Virtual Machine", + "RationaleStatement": "Integrating multi-factor authentication (MFA) as part of the organizational policy can greatly reduce the risk of an identity gaining control of valid credentials that may be used for additional tactics such as initial access, lateral movement, and collecting information. MFA can also be used to restrict access to cloud resources and APIs. An Adversary may log into accessible cloud services within a compromised environment using Valid Accounts that are synchronized to move laterally and perform actions with the virtual machine's managed identity. The adversary may then perform management actions or access cloud-hosted resources as the logged-on managed identity.", + "ImpactStatement": "This recommendation requires the Entra ID P2 license to implement. Ensure that identities provisioned to a virtual machine utilize an RBAC/ABAC group and are allocated a role using Azure PIM, and that the role settings require MFA or use another third-party PAM solution for accessing virtual machines.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Log in to the Azure portal. 2. This can be remediated by enabling MFA for user, Removing user access or Reducing access of managed identities attached to virtual machines. - Case I : Enable MFA for users having access on virtual machines. 1. Go to `Microsoft Entra ID`. 1. For `Per-user MFA`: 1. Under `Manage`, click `Users`. 1. Click `Per-user MFA`. 1. For each user requiring remediation, check the box next to their name. 1. Click `Enable MFA`. 1. Click `Enable`. 1. For `Conditional Access`: 1. Under `Manage`, click `Security`. 1. Under `Protect`, click `Conditional Access`. 1. Update the Conditional Access policy requiring MFA for all users, removing each user requiring remediation from the `Exclude` list. - Case II : Removing user access on a virtual machine. 1. Select the `Subscription`, then click on `Access control (IAM)`. 2. Select `Role assignments` and search for `Virtual Machine Administrator Login` or `Virtual Machine User Login` or any role that provides access to log into virtual machines. 3. Click on `Role Name`, Select `Assignments`, and remove identities with no MFA configured. - Case III : Reducing access of managed identities attached to virtual machines. 1. Select the `Subscription`, then click on `Access control (IAM)`. 2. Select `Role Assignments` from the top menu and apply filters on `Assignment type` as `Privileged administrator roles` and `Type` as `Virtual Machines`. 3. Click on `Role Name`, Select `Assignments`, and remove identities access make sure this follows the least privileges principal.", + "AuditProcedure": "**Audit from Azure Portal** 1. Log in to the Azure portal. 1. Select the `Subscription`, then click on `Access control (IAM)`. 1. Click `Role : All` and click `All` to display the drop-down menu. 1. Type `Virtual Machine Administrator Login` and select `Virtual Machine Administrator Login`. 1. Review the list of identities that have been assigned the `Virtual Machine Administrator Login` role. 1. Go to `Microsoft Entra ID`. 1. For `Per-user MFA`: 1. Under `Manage`, click `Users`. 1. Click `Per-user MFA`. 1. Ensure that none of the identities assigned the `Virtual Machine Administrator Login` role from step 4 have `Status` set to `disabled`. 1. For `Conditional Access`: 1. Under `Manage`, click `Security`. 1. Under `Protect`, click `Conditional Access`. 1. Ensure that none of the identities assigned the `Virtual Machine Administrator Login` role from step 4 are exempt from a Conditional Access policy requiring MFA for all users.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.1", + "Description": "Ensure that 'security defaults' is Enabled in Microsoft Entra ID", + "Checks": [ + "entra_security_defaults_enabled" + ], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.1 Security Defaults (Per-User MFA)", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that 'security defaults' is Enabled in Microsoft Entra ID", + "RationaleStatement": "Security defaults provide secure default settings that we manage on behalf of organizations to keep customers safe until they are ready to manage their own identity security settings. For example, doing the following: - Requiring all users and admins to register for MFA. - Challenging users with MFA - when necessary, based on factors such as location, device, role, and task. - Disabling authentication from legacy authentication clients, which cant do MFA.", + "ImpactStatement": "This recommendation should be implemented initially and then may be overridden by other service/product specific CIS Benchmarks. Administrators should also be aware that certain configurations in Microsoft Entra ID may impact other Microsoft services such as Microsoft 365.", + "RemediationProcedure": "**Remediate from Azure Portal** To enable security defaults in your directory: 1. From Azure Home select the Portal Menu. 1. Browse to `Microsoft Entra ID` > `Properties`. 1. Select `Manage security defaults`. 1. Under `Security defaults`, select `Enabled (recommended)`. 1. Select `Save`.", + "AuditProcedure": "**Audit from Azure Portal** To ensure security defaults is enabled in your directory: 1. From Azure Home select the Portal Menu. 2. Browse to `Microsoft Entra ID` > `Properties`. 3. Select `Manage security defaults`. 4. Under `Security defaults`, verify that `Enabled (recommended)` is selected.", + "AdditionalInformation": "This recommendation differs from the [Microsoft 365 Benchmark](https://workbench.cisecurity.org/benchmarks/5741). This is because the potential impact associated with disabling Security Defaults is dependent upon the security settings implemented in the environment. It is recommended that organizations disabling Security Defaults implement appropriate security settings to replace the settings configured by Security Defaults.", + "References": "https://docs.microsoft.com/en-us/azure/active-directory/fundamentals/concept-fundamentals-security-defaults:https://techcommunity.microsoft.com/t5/azure-active-directory-identity/introducing-security-defaults/ba-p/1061414:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-2-protect-identity-and-authentication-systems", + "DefaultValue": "If your tenant was created on or after October 22, 2019, security defaults may already be enabled in your tenant." + } + ] + }, + { + "Id": "5.1.2", + "Description": "Ensure that 'Require Multifactor Authentication to register or join devices with Microsoft Entra' is set to 'Yes'", + "Checks": [], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.1 Security Defaults (Per-User MFA)", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that 'Require Multifactor Authentication to register or join devices with Microsoft Entra' is set to 'Yes'", + "RationaleStatement": "Multi-factor authentication is recommended when adding devices to Microsoft Entra ID. When set to `Yes`, users who are adding devices from the internet must first use the second method of authentication before their device is successfully added to the directory. This ensures that rogue devices are not added to the domain using a compromised user account.", + "ImpactStatement": "A slight impact of additional overhead, as Administrators will now have to approve every access to the domain.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Entra ID` 1. Under `Manage`, select `Devices` 1. Under `Manage`, select `Device settings` 1. Under `Microsoft Entra join and registration settings`, set `Require Multifactor Authentication to register or join devices with Microsoft Entra` to `Yes` 1. Click `Save`", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Entra ID` 1. Under `Manage`, select `Devices` 1. Under `Manage`, select `Device settings` 1. Under `Microsoft Entra join and registration settings`, ensure that `Require Multifactor Authentication to register or join devices with Microsoft Entra` is set to `Yes`", + "AdditionalInformation": "If Conditional Access is available, this recommendation should be bypassed in favor of the Conditional Access implementation of requiring Multifactor Authentication to register or join devices with Microsoft Entra. https://learn.microsoft.com/en-us/entra/identity/conditional-access/how-to-policy-mfa-device-register-join", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/how-to-policy-mfa-device-register-join:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-6-use-strong-authentication-controls", + "DefaultValue": "By default, `Require Multifactor Authentication to register or join devices with Microsoft Entra` is set to `No`." + } + ] + }, + { + "Id": "5.1.3", + "Description": "Ensure that 'multifactor authentication' is 'enabled' For All Users", + "Checks": [ + "entra_privileged_user_has_mfa", + "entra_non_privileged_user_has_mfa" + ], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.1 Security Defaults (Per-User MFA)", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that 'multifactor authentication' is 'enabled' For All Users", + "RationaleStatement": "Multifactor authentication requires an individual to present a minimum of two separate forms of authentication before access is granted. Multifactor authentication provides additional assurance that the individual attempting to gain access is who they claim to be. With multifactor authentication, an attacker would need to compromise at least two different authentication mechanisms, increasing the difficulty of compromise and thus reducing the risk.", + "ImpactStatement": "Users would require two forms of authentication before any access is granted. Additional administrative time will be required for managing dual forms of authentication when enabling multifactor authentication.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Entra ID`. 1. Under `Manage`, click `Users`. 1. Click `Per-user MFA` from the top menu. 1. Click the box next to a user with `Status` `disabled`. 1. Click `Enable MFA`. 1. Click `Enable`. 1. Repeat steps 1-6 for each user requiring remediation. **Other options within Azure Portal** - [https://docs.microsoft.com/en-us/azure/active-directory/authentication/tutorial-enable-azure-mfa](https://docs.microsoft.com/en-us/azure/active-directory/authentication/tutorial-enable-azure-mfa) - [https://docs.microsoft.com/en-us/azure/active-directory/authentication/howto-mfa-mfasettings](https://docs.microsoft.com/en-us/azure/active-directory/authentication/howto-mfa-mfasettings) - [https://docs.microsoft.com/en-us/azure/active-directory/conditional-access/howto-conditional-access-policy-admin-mfa](https://docs.microsoft.com/en-us/azure/active-directory/conditional-access/howto-conditional-access-policy-admin-mfa) - [https://docs.microsoft.com/en-us/azure/active-directory/authentication/howto-mfa-getstarted#enable-multi-factor-authentication-with-conditional-access](https://docs.microsoft.com/en-us/azure/active-directory/authentication/howto-mfa-getstarted#enable-multi-factor-authentication-with-conditional-access)", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Entra ID`. 1. Under `Manage`, click `Users`. 1. Click `Per-user MFA` from the top menu. 1. Ensure that `Status` is `enabled` for all users. **Audit from REST API** Run the following Graph PowerShell command: ``` get-mguser -All | where {$_.StrongAuthenticationMethods.Count -eq 0} | Select-Object -Property UserPrincipalName ``` If the output contains any `UserPrincipalName`, then this recommendation is non-compliant.", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/multi-factor-authentication/multi-factor-authentication:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-mandatory-multifactor-authentication:https://azure.microsoft.com/en-us/blog/announcing-mandatory-multi-factor-authentication-for-azure-sign-in/:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-4-authenticate-server-and-services", + "DefaultValue": "Multifactor authentication is not enabled for all users by default. Starting in 2024, multifactor authentication is enabled for administrative accounts by default." + } + ] + }, + { + "Id": "5.1.4", + "Description": "Ensure that 'Allow users to remember multifactor authentication on devices they trust' is Disabled", + "Checks": [], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.1 Security Defaults (Per-User MFA)", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that 'Allow users to remember multifactor authentication on devices they trust' is Disabled", + "RationaleStatement": "Remembering Multi-Factor Authentication (MFA) for devices and browsers allows users to have the option to bypass MFA for a set number of days after performing a successful sign-in using MFA. This can enhance usability by minimizing the number of times a user may need to perform two-step verification on the same device. However, if an account or device is compromised, remembering MFA for trusted devices may affect security. Hence, it is recommended that users not be allowed to bypass MFA.", + "ImpactStatement": "For every login attempt, the user will be required to perform multi-factor authentication.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Entra ID` 1. Under `Manage`, click `Users` 1. Click the `Per-user MFA` button on the top bar 1. Click on `Service settings` 1. Uncheck the box next to `Allow users to remember multi-factor authentication on devices they trust` 1. Click `Save`", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Entra ID` 1. Under `Manage`, click `Users` 1. Click the `Per-user MFA` button on the top bar 1. Click on `Service settings` 1. Ensure that `Allow users to remember multi-factor authentication on devices they trust` is not enabled", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/howto-mfa-mfasettings#remember-multi-factor-authentication-for-devices-that-users-trust:https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-identity-management#im-4-use-strong-authentication-controls-for-all-azure-active-directory-based-access:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-6-use-strong-authentication-controls", + "DefaultValue": "By default, `Allow users to remember multi-factor authentication on devices they trust` is disabled." + } + ] + }, + { + "Id": "5.3.1", + "Description": "Ensure that Azure Admin Accounts Are Not Used for Daily Operations", + "Checks": [], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.3 Periodic Identity Reviews", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that Azure Admin Accounts Are Not Used for Daily Operations", + "RationaleStatement": "Using admin accounts for daily operations increases the risk of accidental misconfigurations and security breaches.", + "ImpactStatement": "Minor administrative overhead includes managing separate accounts, enforcing stricter access controls, and potential licensing costs for advanced security features.", + "RemediationProcedure": "If admin accounts are being used for daily operations, consider the following: - Monitor and alert on unusual activity. - Enforce the principle of least privilege. - Revoke any unnecessary administrative access. - Use Conditional Access to limit access to resources. - Ensure that administrators have separate admin and user accounts. - Use Microsoft Entra ID Protection helps organizations detect, investigate, and remediate identity-based risks. - Use Privileged Identity Management (PIM) in Microsoft Entra ID to limit standing administrator access to privileged roles, discover who has access, and review privileged access.", + "AuditProcedure": "**Audit from Azure Portal** Monitor: 1. Go to `Monitor`. 1. Click `Activity log`. 1. Review the activity log and ensure that admin accounts are not being used for daily operations. Microsoft Entra ID: 1. Go to `Microsoft Entra ID`. 1. Under `Monitoring`, click `Sign-in logs`. 1. Review the sign-in logs and ensure that admin accounts are not being accessed more frequently than necessary.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/security/privileged-access-workstations/critical-impact-accounts", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.3.2", + "Description": "Ensure that Guest Users are Reviewed on a Regular Basis", + "Checks": [ + "entra_policy_guest_users_access_restrictions" + ], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.3 Periodic Identity Reviews", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that Guest Users are Reviewed on a Regular Basis", + "RationaleStatement": "Guest users are typically added outside your employee on-boarding/off-boarding process and could potentially be overlooked indefinitely. To prevent this, guest users should be reviewed on a regular basis. During this audit, guest users should also be determined to not have administrative privileges.", + "ImpactStatement": "Before removing guest users, determine their use and scope. Like removing any user, there may be unforeseen consequences to systems if an account is removed without careful consideration.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Entra ID` 1. Under `Manage`, select `Users` 1. Click on `Add filter` 1. Select `User type` 1. Select `Guest` from the Value dropdown 1. Click `Apply` 1. Check the box next to all `Guest` users that are no longer required or are inactive 1. Click `Delete` 1. Click `OK` **Remediate from Azure CLI** Before deleting the user, set it to inactive using the ID from the Audit Procedure to determine if there are any dependent systems. ``` az ad user update --id --account-enabled {false} ``` After determining that there are no dependent systems, delete the user. ``` Remove-AzureADUser -ObjectId ``` **Remediate from Azure PowerShell** Before deleting the user, set it to inactive using the ID from the Audit Procedure to determine if there are any dependent systems. ``` Set-AzureADUser -ObjectId -AccountEnabled false ``` After determining that there are no dependent systems, delete the user. ``` PS C:\\>Remove-AzureADUser -ObjectId ```", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Entra ID` 1. Under `Manage`, select `Users` 1. Click on `Add filter` 1. Select `User type` 1. Select `Guest` from the Value dropdown 1. Click `Apply` 1. Audit the listed guest users **Audit from Azure CLI** ``` az ad user list --query [?userType=='Guest'] ``` Ensure all users listed are still required and not inactive. **Audit from Azure PowerShell** ``` Get-AzureADUser |Where-Object {$_.UserType -like Guest} |Select-Object DisplayName, UserPrincipalName, UserType -Unique ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [e9ac8f8e-ce22-4355-8f04-99b911d6be52](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fe9ac8f8e-ce22-4355-8f04-99b911d6be52) **- Name:** 'Guest accounts with read permissions on Azure resources should be removed' - **Policy ID:** [94e1c2ac-cbbe-4cac-a2b5-389c812dee87](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F94e1c2ac-cbbe-4cac-a2b5-389c812dee87) **- Name:** 'Guest accounts with write permissions on Azure resources should be removed' - **Policy ID:** [339353f6-2387-4a45-abe4-7f529d121046](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F339353f6-2387-4a45-abe4-7f529d121046) **- Name:** 'Guest accounts with owner permissions on Azure resources should be removed'", + "AdditionalInformation": "It is good practice to use a dynamic security group to manage guest users. To create the dynamic security group: 1. Navigate to the 'Microsoft Entra ID' blade in the Azure Portal 2. Select the 'Groups' item 3. Create new 4. Type of 'dynamic' 5. Use the following dynamic selection rule. (user.userType -eq Guest) 6. Once the group has been created, select access reviews option and create a new access review with a period of monthly and send to relevant administrators for review.", + "References": "https://learn.microsoft.com/en-us/entra/external-id/user-properties:https://learn.microsoft.com/en-us/entra/fundamentals/how-to-create-delete-users#delete-a-user:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-4-review-and-reconcile-user-access-regularly:https://www.microsoft.com/en-us/security/business/identity-access-management/azure-ad-pricing:https://learn.microsoft.com/en-us/entra/identity/monitoring-health/howto-manage-inactive-user-accounts:https://learn.microsoft.com/en-us/entra/fundamentals/users-restore", + "DefaultValue": "By default no guest users are created." + } + ] + }, + { + "Id": "5.3.3", + "Description": "Ensure That Use of the 'User Access Administrator' Role is Restricted", + "Checks": [ + "iam_role_user_access_admin_restricted" + ], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.3 Periodic Identity Reviews", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure That Use of the 'User Access Administrator' Role is Restricted", + "RationaleStatement": "The User Access Administrator role provides extensive access control privileges. Unnecessary assignments heighten the risk of privilege escalation and unauthorized access. Removing the role immediately after use minimizes security exposure.", + "ImpactStatement": "Increased administrative effort to manage and remove role assignments appropriately.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Subscriptions`. 1. Select a subscription. 1. Select `Access control (IAM)`. 1. Look for the following banner at the top of the page: `Action required: X users have elevated access in your tenant. You should take immediate action and remove all role assignments with elevated access.` 1. Click `View role assignments`. 1. Click `Remove`. **Remediate from Azure CLI** Run the following command: ``` az role assignment delete --role User Access Administrator --scope / ```", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Subscriptions`. 1. Select a subscription. 1. Select `Access control (IAM)`. 1. Look for the following banner at the top of the page: `Action required: X users have elevated access in your tenant. You should take immediate action and remove all role assignments with elevated access.` If the banner is displayed, the `User Access Administrator` is assigned. **Audit from Azure CLI** Run the following command: ``` az role assignment list --role User Access Administrator --scope / ``` Ensure that the command does not return any `User Access Administrator` role assignment information.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles:https://learn.microsoft.com/en-us/azure/role-based-access-control/elevate-access-global-admin", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.3.4", + "Description": "Ensure that All 'Privileged' Role Assignments are Periodically Reviewed", + "Checks": [], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.3 Periodic Identity Reviews", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that All 'Privileged' Role Assignments are Periodically Reviewed", + "RationaleStatement": "Privileged roles are crown jewel assets that can be used by malicious insiders, threat actors, and even through mistake to significantly damage an organization. These roles should be periodically reviewed to identify lingering permissions assignment and detect lateral movement through privilege escalation.", + "ImpactStatement": "Increased administrative effort to manage and remove role assignments appropriately.", + "RemediationProcedure": "Review privileged role assignments and remove any that are no longer necessary or appropriate. Use Azure PIM (Privileged Identity Management) to implement just-in-time access for privileged roles.", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu. 2. Select `Subscriptions`. 3. Select a subscription. 4. Select `Access control (IAM)`. 5. Look for the number under the word `Privileged` accompanied by a link titled `View Assignments`. Click the `View assignments` link. 6. For each privileged role listed, evaluate whether the assignment is appropriate and current for each User, Group, or App assigned to each privileged role. NOTE: Determining 'appropriate' assignments requires a clear understanding of your organization's personnel, systems, policy, and security requirements.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.3.5", + "Description": "Ensure Disabled User Accounts do not Have Read, Write, or Owner Permissions", + "Checks": [], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.3 Periodic Identity Reviews", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure Disabled User Accounts do not Have Read, Write, or Owner Permissions", + "RationaleStatement": "Disabled accounts should not retain access to resources, as this poses a security risk. Removing role assignments mitigates potential unauthorized access and enforces the principle of least privilege.", + "ImpactStatement": "Ensure disabled accounts are not relied on for break glass or automated processes before removing roles to avoid service disruption.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Entra ID`. 2. Under `Manage`, click `Users`. 3. Click `Add filter`. 4. Click `Account enabled`. 5. Click the toggle switch to set the value to `No`. 6. Click `Apply`. 7. Click the `Display name` of a disabled user account with read, write, or owner roles assigned. 8. Click `Azure role assignments`. 9. Click the name of a read, write, or owner role. 10. Click `Assignments`. 11. Click `Remove` in the row for the disabled user account. 12. Click `Yes`. 13. Repeat steps 7-12 for disabled user accounts requiring remediation. **Remediate from PowerShell** ``` Remove-AzRoleAssignment -ObjectId $user.ObjectId -RoleDefinitionName ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Entra ID`. 2. Under `Manage`, click `Users`. 3. Click `Add filter`. 4. Click `Account enabled`. 5. Click the toggle switch to set the value to `No`. 6. Click `Apply`. 7. Click the `Display name` of a disabled user account. 8. Click `Azure role assignments`. 9. Ensure that no read, write, or owner roles are assigned to the user account. 10. Repeat steps 7-9 for each disabled user account. **Audit from PowerShell** ``` Connect-AzureAD Get-AzureADUser $user = Get-AzureADUser -ObjectId $user.AccountEnabled ``` If AccountEnabled is False, run: ``` Get-AzRoleAssignment -ObjectId $user.ObjectId ``` **Audit from Azure Policy** - **Policy ID:** [0cfea604-3201-4e14-88fc-fae4c427a6c5] - Name: 'Blocked accounts with owner permissions on Azure resources should be removed' - **Policy ID:** [8d7e1fde-fe26-4b5f-8108-f8e432cbc2be] - Name: 'Blocked accounts with read and write permissions on Azure resources should be removed'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/powershell/module/az.resources/get-azaduser:https://learn.microsoft.com/en-us/powershell/module/az.resources/get-azroleassignment:https://learn.microsoft.com/en-us/powershell/module/az.resources/remove-azroleassignment", + "DefaultValue": "Disabled user accounts retain their prior role assignments." + } + ] + }, + { + "Id": "5.3.6", + "Description": "Ensure 'Tenant Creator' Role Assignments are Periodically Reviewed", + "Checks": [], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.3 Periodic Identity Reviews", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure 'Tenant Creator' Role Assignments are Periodically Reviewed", + "RationaleStatement": "Unnecessary assignments increase the risk of privilege escalation and unauthorized access. This recommendation should be applied alongside the recommendation 'Ensure that Restrict non-admin users from creating tenants is set to Yes'.", + "ImpactStatement": "Verify that the Tenant Creator role is no longer required by any assignments before removal to avoid disruption of critical functions.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Entra ID`. 2. Under `Manage`, click `Roles and administrators`. 3. In the search bar, type `Tenant Creator`. 4. Click the role. 5. Click the name of an assignment. 6. Check the box next to the `Tenant Creator` role. 7. Click `X Remove assignments`. 8. Click `Yes`. 9. Repeat steps 1-8 for each assignment requiring remediation.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Entra ID`. 2. Under `Manage`, click `Roles and administrators`. 3. In the search bar, type `Tenant Creator`. 4. Click the role. 5. Review the assignments and ensure that they are appropriate.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/active-directory-b2c/tenant-management-check-tenant-creation-permission:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#tenant-creator", + "DefaultValue": "The Tenant Creator role is not assigned by default." + } + ] + }, + { + "Id": "5.3.7", + "Description": "Ensure All Non-privileged Role Assignments are Periodically Reviewed", + "Checks": [], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.3 Periodic Identity Reviews", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure All Non-privileged Role Assignments are Periodically Reviewed", + "RationaleStatement": "To ensure the principle of least privilege is followed, non-privileged role assignments should be reviewed periodically to confirm that users are granted only the minimum level of permissions they need to perform their tasks.", + "ImpactStatement": "Increased administrative effort to manage and remove role assignments appropriately.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Subscriptions`. 2. Click the name of a subscription. 3. Click `Access control (IAM)`. 4. Click `Role assignments`. 5. Click `Job function roles`. 6. Check the box next to any inappropriate assignments. 7. Click `Delete`. 8. Click `Yes`. 9. Repeat steps 1-8 for each subscription.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Subscriptions`. 2. Click the name of a subscription. 3. Click `Access control (IAM)`. 4. Click `Role assignments`. 5. Click `Job function roles`. 6. For each role, ensure the assignments are appropriate. 7. Repeat steps 1-6 for each subscription.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments", + "DefaultValue": "Users do not have non-privileged roles assigned to them by default." + } + ] + }, + { + "Id": "5.4", + "Description": "Ensure that No Custom Subscription Administrator Roles Exist", + "Checks": [ + "iam_subscription_roles_owner_custom_not_created" + ], + "Attributes": [ + { + "Section": "5 Identity Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that No Custom Subscription Administrator Roles Exist", + "RationaleStatement": "Custom roles in Azure with administrative access can obfuscate the permissions granted and introduce complexity and blind spots to the management of privileged identities. For less mature security programs without regular identity audits, the creation of Custom roles should be avoided entirely. For more mature security programs with regular identity audits, Custom Roles should be audited for use and assignment, used minimally, and the principle of least privilege should be observed when granting permissions", + "ImpactStatement": "Subscriptions will need to be handled by Administrators with permissions.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Subscriptions`. 1. Select a subscription. 1. Select `Access control (IAM)`. 1. Select `Roles`. 1. Click `Type` and select `Custom role` from the drop-down menu. 1. Check the box next to each role which grants subscription administrator privileges. 1. Select `Delete`. 1. Select `Yes`. **Remediate from Azure CLI** List custom roles: ``` az role definition list --custom-role-only True ``` Check for entries with `assignableScope` of the `subscription`, and an action of `*`. To remove a violating role: ``` az role definition delete --name ``` Note that any role assignments must be removed before a custom role can be deleted. Ensure impact is assessed before deleting a custom role granting subscription administrator privileges.", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Subscriptions`. 1. Select a subscription. 1. Select `Access control (IAM)`. 1. Select `Roles`. 1. Click `Type` and select `Custom role` from the drop-down menu. 1. Select `View` next to a role. 1. Select `JSON`. 1. Check for `assignableScopes` set to the subscription, and `actions` set to `*`. 1. Repeat steps 7-9 for each custom role. **Audit from Azure CLI** List custom roles: ``` az role definition list --custom-role-only True ``` Check for entries with `assignableScope` of the `subscription`, and an action of `*` **Audit from PowerShell** ``` Connect-AzAccount Get-AzRoleDefinition |Where-Object {($_.IsCustom -eq $true) -and ($_.Actions.contains('*'))} ``` Check the output for `AssignableScopes` value set to the subscription. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [a451c1ef-c6ca-483d-87ed-f49761e3ffb5](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fa451c1ef-c6ca-483d-87ed-f49761e3ffb5) **- Name:** 'Audit usage of custom RBAC roles'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/billing/billing-add-change-azure-subscription-administrator:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-1-separate-and-limit-highly-privilegedadministrative-users:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-3-manage-lifecycle-of-identities-and-entitlements:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-2-define-and-implement-enterprise-segmentationseparation-of-duties-strategy:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-6-define-and-implement-identity-and-privileged-access-strategy:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-7-follow-just-enough-administration-least-privilege-principle", + "DefaultValue": "By default, no custom owner roles are created." + } + ] + }, + { + "Id": "5.5", + "Description": "Ensure that a Custom Role is Assigned Permissions for Administering Resource Locks", + "Checks": [ + "iam_custom_role_has_permissions_to_administer_resource_locks" + ], + "Attributes": [ + { + "Section": "5 Identity Services", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that a Custom Role is Assigned Permissions for Administering Resource Locks", + "RationaleStatement": "Given that the resource lock functionality is outside of standard Role-Based Access Control (RBAC), it would be prudent to create a resource lock administrator role to prevent inadvertent unlocking of resources.", + "ImpactStatement": "By adding this role, specific permissions may be granted for managing only resource locks rather than needing to provide the broad Owner or User Access Administrator role, reducing the risk of the user being able to cause unintentional damage.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. In the Azure portal, navigate to a subscription or resource group. 1. Click `Access control (IAM)`. 1. Click `+ Add`. 1. Click `Add custom role`. 1. In the `Custom role name` field enter `Resource Lock Administrator`. 1. In the `Description` field enter `Can Administer Resource Locks`. 1. For `Baseline permissions` select `Start from scratch`. 1. Click `Next`. 1. Click `Add permissions`. 1. In the `Search for a permission` box, type `Microsoft.Authorization/locks`. 1. Click the result. 1. Check the box next to `Permission`. 1. Click `Add`. 1. Click `Review + create`. 1. Click `Create`. 1. Click `OK`. 1. Click `+ Add`. 1. Click `Add role assignment`. 1. In the `Search by role name, description, permission, or ID` box, type `Resource Lock Administrator`. 1. Select the role. 1. Click `Next`. 1. Click `+ Select members`. 1. Select appropriate members. 1. Click `Select`. 1. Click `Review + assign`. 1. Click `Review + assign` again. 1. Repeat steps 1-26 for each subscription or resource group requiring remediation. **Remediate from PowerShell:** Below is a PowerShell definition for a resource lock administrator role created at an Azure Management group level ``` Import-Module Az.Accounts Connect-AzAccount $role = Get-AzRoleDefinition User Access Administrator $role.Id = $null $role.Name = Resource Lock Administrator $role.Description = Can Administer Resource Locks $role.Actions.Clear() $role.Actions.Add(Microsoft.Authorization/locks/*) $role.AssignableScopes.Clear() * Scope at the Management group level Management group $role.AssignableScopes.Add(/providers/Microsoft.Management/managementGroups/MG-Name) New-AzRoleDefinition -Role $role Get-AzureRmRoleDefinition Resource Lock Administrator ```", + "AuditProcedure": "**Audit from Azure Portal** 1. In the Azure portal, navigate to a subscription or resource group. 1. Click `Access control (IAM)`. 1. Click `Roles`. 1. Click `Type : All`. 1. Click to view the drop-down menu. 1. Select `Custom role`. 1. Click `View` in the `Details` column of a custom role. 1. Review the role permissions. 1. Click `Assignments` and review the assignments. 1. Click the `X` to exit the custom role details page. 1. Repeat steps 7-10. Ensure that at least one custom role exists that assigns the `Microsoft.Authorization/locks` permission to appropriate members. 1. Repeat steps 1-11 for each subscription or resource group.", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/role-based-access-control/custom-roles:https://docs.microsoft.com/en-us/azure/role-based-access-control/check-access:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-1-separate-and-limit-highly-privilegedadministrative-users:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-7-follow-just-enough-administration-least-privilege-principle:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-3-manage-lifecycle-of-identities-and-entitlements:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-2-define-and-implement-enterprise-segmentationseparation-of-duties-strategy:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-6-define-and-implement-identity-and-privileged-access-strategy", + "DefaultValue": "A role for administering resource locks does not exist by default." + } + ] + }, + { + "Id": "5.6", + "Description": "Ensure that 'Subscription leaving Microsoft Entra tenant' and 'Subscription entering Microsoft Entra tenant' is set to 'Permit no one'", + "Checks": [], + "Attributes": [ + { + "Section": "5 Identity Services", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that 'Subscription leaving Microsoft Entra tenant' and 'Subscription entering Microsoft Entra tenant' is set to 'Permit no one'", + "RationaleStatement": "Permissions to move subscriptions in and out of a Microsoft Entra tenant must only be given to appropriate administrative personnel. A subscription that is moved into a Microsoft Entra tenant may be within a folder to which other users have elevated permissions. This prevents loss of data or unapproved changes of the objects within by potential bad actors.", + "ImpactStatement": "Subscriptions will need to have these settings turned off to be moved.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From the Azure Portal Home select the portal menu 1. Select `Subscriptions` 1. In the `Advanced options` drop-down menu, select `Manage Policies` 1. Set `Subscription leaving Microsoft Entra tenant` and `Subscription entering Microsoft Entra tenant` to `Permit no one` 1. Click `Save changes`", + "AuditProcedure": "**Audit from Azure Portal** 1. From the Azure Portal Home select the portal menu 1. Select `Subscriptions` 1. In the `Advanced options` drop-down menu, select `Manage Policies` 1. Ensure `Subscription leaving Microsoft Entra tenant` and `Subscription entering Microsoft Entra tenant` are set to `Permit no one`", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/cost-management-billing/manage/manage-azure-subscription-policy:https://learn.microsoft.com/en-us/entra/fundamentals/how-subscriptions-associated-directory:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-2-protect-identity-and-authentication-systems", + "DefaultValue": "By default `Subscription leaving Microsoft Entra tenant` and `Subscription entering Microsoft Entra tenant` are set to `Allow everyone (default)`" + } + ] + }, + { + "Id": "5.7", + "Description": "Ensure there are between 2 and 3 Subscription Owners", + "Checks": [], + "Attributes": [ + { + "Section": "5 Identity Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure there are between 2 and 3 Subscription Owners", + "RationaleStatement": "If groups are used, ensure their membership is tightly controlled and regularly reviewed to avoid privilege sprawl. This includes user accounts, Entra ID groups, service principals, and managed identities.", + "ImpactStatement": "Implementation may require changes in administrative workflows or the redistribution of roles and responsibilities.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Subscriptions`. 2. Click the name of a subscription. 3. Click `Access Controls (IAM)`. 4. Click `Role assignments`. 5. Click `Role : All`. 6. Click `Owner`. 7. Check the box next to members from whom the owner role should be removed. 8. Click `Delete`. 9. Click `Yes`. 10. Repeat for each subscription requiring remediation. **Remediate from Azure CLI** ``` az role assignment delete --ids ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Subscriptions`. 2. Click the name of a subscription. 3. Click `Access Controls (IAM)`. 4. Click `Role assignments`. 5. Click `Role : All`. 6. Click the arrow next to `All`. 7. Click `Owner`. 8. Ensure a minimum of 2 and a maximum of 3 members are returned. 9. Repeat steps 1-8 for each subscription. **Audit from Azure CLI** ``` az role assignment list --role Owner --scope /subscriptions/ --query \"[].{PrincipalName:principalName, Type:principalType}\" ``` Ensure a minimum of 2 and a maximum of 3 members are returned. **Audit from PowerShell** ``` Get-AzRoleAssignment -RoleDefinitionName Owner -Scope /subscriptions/ ``` **Audit from Azure Policy** - **Policy ID:** [09024ccc-0c5f-475e-9457-b7c0d9ed487b] - Name: 'There should be more than one owner assigned to your subscription' - **Policy ID:** [4f11b553-d42e-4e3a-89be-32ca364cad4c] - Name: 'A maximum of 3 owners should be designated for your subscription'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/cli/azure/role/assignment:https://learn.microsoft.com/en-us/powershell/module/az.resources/get-azroleassignment:https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/privileged#owner", + "DefaultValue": "A subscription has 1 owner by default." + } + ] + }, + { + "Id": "6.1.1.1", + "Description": "Ensure that a 'Diagnostic Setting' Exists for Subscription Activity Logs", + "Checks": [ + "monitor_diagnostic_settings_exists" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that a 'Diagnostic Setting' Exists for Subscription Activity Logs", + "RationaleStatement": "A diagnostic setting controls how a diagnostic log is exported. By default, logs are retained only for 90 days. Diagnostic settings should be defined so that logs can be exported and stored for a longer duration to analyze security activities within an Azure subscription.", + "ImpactStatement": "Diagnostic settings incur costs based on the amount of data collected and the destination.", + "RemediationProcedure": "**Remediate from Azure Portal** To enable Diagnostic Settings on a Subscription: 1. Go to `Monitor` 2. Click on `Activity log` 3. Click on `Export Activity Logs` 4. Click `+ Add diagnostic setting` 5. Enter a `Diagnostic setting name` 6. Select `Categories` for the diagnostic setting 7. Select the appropriate `Destination details` (this may be Log Analytics, Storage Account, Event Hub, or Partner solution) 8. Click `Save` To enable Diagnostic Settings on a specific resource: 1. Go to `Monitoring` 1. Click `Diagnostic settings` 1. Select `Add diagnostic setting` 1. Enter a `Diagnostic setting name` 1. Select the appropriate log, metric, and destination (this may be Log Analytics, Storage Account, Event Hub, or Partner solution) 1. Click `Save` Repeat these step for all resources as needed. **Remediate from Azure CLI** To configure Diagnostic Settings on a Subscription: ``` az monitor diagnostic-settings subscription create --subscription --name --location <[--event-hub --event-hub-auth-rule ] [--storage-account ] [--workspace ] --logs (e.g. [{category:Security,enabled:true},{category:Administrative,enabled:true},{category:Alert,enabled:true},{category:Policy,enabled:true}]) ``` To configure Diagnostic Settings on a specific resource: ``` az monitor diagnostic-settings create --subscription --resource --name <[--event-hub --event-hub-rule ] [--storage-account ] [--workspace ] --logs --metrics ``` **Remediate from PowerShell** To configure Diagnostic Settings on a subscription: ``` $logCategories = @(); $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Administrative -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Security -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category ServiceHealth -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Alert -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Recommendation -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Policy -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Autoscale -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category ResourceHealth -Enabled $true New-AzSubscriptionDiagnosticSetting -SubscriptionId -Name <[-EventHubAuthorizationRule -EventHubName ] [-StorageAccountId ] [-WorkSpaceId ] [-MarketplacePartner ID ]> -Log $logCategories ``` To configure Diagnostic Settings on a specific resource: ``` $logCategories = @() $logCategories += New-AzDiagnosticSettingLogSettingsObject -Category -Enabled $true Repeat command and variable assignment for each Log category specific to the resource where this Diagnostic Setting will get configured. $metricCategories = @() $metricCategories += New-AzDiagnosticSettingMetricSettingsObject -Enabled $true [-Category ] [-RetentionPolicyDay ] [-RetentionPolicyEnabled $true] Repeat command and variable assignment for each Metric category or use the 'AllMetrics' category. New-AzDiagnosticSetting -ResourceId -Name -Log $logCategories -Metric $metricCategories [-EventHubAuthorizationRuleId -EventHubName ] [-StorageAccountId ] [-WorkspaceId ] [-MarketplacePartnerId ]>", + "AuditProcedure": "**Audit from Azure Portal** To identify Diagnostic Settings on a subscription: 1. Go to `Monitor` 2. Click `Activity Log` 3. Click `Export Activity Logs` 4. Select a `Subscription` 5. Ensure a `Diagnostic setting` exists for the selected Subscription To identify Diagnostic Settings on specific resources: 1. Go to `Monitoring` 2. Click `Diagnostic settings` 3. Ensure a `Diagnostic setting` exists for all appropriate resources. **Audit from Azure CLI** To identify Diagnostic Settings on a subscription: ``` az monitor diagnostic-settings subscription list --subscription ``` To identify Diagnostic Settings on a resource ``` az monitor diagnostic-settings list --resource ``` **Audit from PowerShell** To identify Diagnostic Settings on a Subscription: ``` Get-AzDiagnosticSetting -SubscriptionId ``` To identify Diagnostic Settings on a specific resource: ``` Get-AzDiagnosticSetting -ResourceId ```", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/monitoring-and-diagnostics/monitoring-overview-activity-logs#export-the-activity-log-with-a-log-profile:https://learn.microsoft.com/en-us/cli/azure/monitor/diagnostic-settings?view=azure-cli-latest:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, diagnostic setting is not set." + } + ] + }, + { + "Id": "6.1.1.2", + "Description": "Ensure Diagnostic Setting Captures Appropriate Categories", + "Checks": [ + "monitor_diagnostic_setting_with_appropriate_categories" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure Diagnostic Setting Captures Appropriate Categories", + "RationaleStatement": "A diagnostic setting controls how the diagnostic log is exported. Capturing the diagnostic setting categories for appropriate control/management plane activities allows proper alerting.", + "ImpactStatement": "Enabling additional categories may increase storage costs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Monitor`. 1. Click `Activity log`. 1. Click on `Export Activity Logs`. 1. Select the `Subscription` from the drop down menu. 1. Click `Edit setting` next to a diagnostic setting. 1. Check the following categories: `Administrative, Alert, Policy, and Security`. 1. Choose the destination details according to your organization's needs. 1. Click `Save`. **Remediate from Azure CLI** ``` az monitor diagnostic-settings subscription create --subscription --name --location <[--event-hub --event-hub-auth-rule ] [--storage-account ] [--workspace ] --logs [{category:Security,enabled:true},{category:Administrative,enabled:true},{category:Alert,enabled:true},{category:Policy,enabled:true}] ``` **Remediate from PowerShell** ``` $logCategories = @(); $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Administrative -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Security -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Alert -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Policy -Enabled $true New-AzSubscriptionDiagnosticSetting -SubscriptionId -Name <[-EventHubAuthorizationRule -EventHubName ] [-StorageAccountId ] [-WorkSpaceId ] [-MarketplacePartner ID ]> -Log $logCategories ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Monitor`. 1. Click `Activity log`. 1. Click on `Export Activity Logs`. 1. Select the appropriate `Subscription`. 1. Click `Edit setting` next to a diagnostic setting. 1. Ensure that the following categories are checked: `Administrative, Alert, Policy, and Security`. **Audit from Azure CLI** Ensure the categories `'Administrative', 'Alert', 'Policy', and 'Security'` set to: 'enabled: true' ``` az monitor diagnostic-settings subscription list --subscription ``` **Audit from PowerShell** Ensure the categories Administrative, Alert, Policy, and Security are set to Enabled:True ``` Get-AzSubscriptionDiagnosticSetting -Subscription ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [3b980d31-7904-4bb7-8575-5665739a8052](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F3b980d31-7904-4bb7-8575-5665739a8052) **- Name:** 'An activity log alert should exist for specific Security operations' - **Policy ID:** [b954148f-4c11-4c38-8221-be76711e194a](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb954148f-4c11-4c38-8221-be76711e194a) **- Name:** 'An activity log alert should exist for specific Administrative operations' - **Policy ID:** [c5447c04-a4d7-4ba8-a263-c9ee321a6858](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc5447c04-a4d7-4ba8-a263-c9ee321a6858) **- Name:** 'An activity log alert should exist for specific Policy operations'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/azure-monitor/platform/diagnostic-settings:https://docs.microsoft.com/en-us/azure/azure-monitor/samples/resource-manager-diagnostic-settings:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation:https://learn.microsoft.com/en-us/cli/azure/monitor/diagnostic-settings?view=azure-cli-latest:https://learn.microsoft.com/en-us/powershell/module/az.monitor/new-azsubscriptiondiagnosticsetting?view=azps-9.2.0", + "DefaultValue": "When the diagnostic setting is created using Azure Portal, by default no categories are selected." + } + ] + }, + { + "Id": "6.1.1.3", + "Description": "Ensure the Storage Account Containing the Container with Activity Logs is Encrypted with Customer-managed Key (CMK)", + "Checks": [ + "monitor_storage_account_with_activity_logs_cmk_encrypted" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure the Storage Account Containing the Container with Activity Logs is Encrypted with Customer-managed Key (CMK)", + "RationaleStatement": "Configuring the storage account with the activity log export container to use CMKs provides additional confidentiality controls on log data, as a given user must have read permission on the corresponding storage account and must be granted decrypt permission by the CMK.", + "ImpactStatement": "**NOTE:** You must have your key vault setup to utilize this. All Audit Logs will be encrypted with a key you provide. You will need to set up customer managed keys separately, and you will select which key to use via the instructions here. You will be responsible for the lifecycle of the keys, and will need to manually replace them at your own determined intervals to keep the data secure.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Monitor`. 1. Select `Activity log`. 1. Select `Export Activity Logs`. 1. Select a `Subscription`. 1. Note the name of the `Storage Account` for the diagnostic setting. 1. Navigate to `Storage accounts`. 1. Click on the storage account. 1. Under `Security + networking`, click `Encryption`. 1. Next to `Encryption type`, select `Customer-managed keys`. 1. Complete the steps to configure a customer-managed key for encryption of the storage account. **Remediate from Azure CLI** ``` az storage account update --name --resource-group --encryption-key-source=Microsoft.Keyvault --encryption-key-vault --encryption-key-name --encryption-key-version ``` **Remediate from PowerShell** ``` Set-AzStorageAccount -ResourceGroupName -Name -KeyvaultEncryption -KeyVaultUri -KeyName ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Monitor`. 1. Select `Activity log`. 1. Select `Export Activity Logs`. 1. Select a `Subscription`. 1. Note the name of the `Storage Account` for the diagnostic setting. 1. Navigate to `Storage accounts`. 1. Click on the storage account name noted in Step 5. 1. Under `Security + networking`, click `Encryption`. 1. Ensure `Customer-managed keys` is selected and a key is set. **Audit from Azure CLI** 1. Get storage account id configured with log profile: ``` az monitor diagnostic-settings subscription list --subscription --query 'value[*].storageAccountId' ``` 2. Ensure the storage account is encrypted with CMK: ``` az storage account list --query [?name==''] ``` In command output ensure `keySource` is set to `Microsoft.Keyvault` and `keyVaultProperties` is not set to `null` **Audit from PowerShell** ``` Get-AzStorageAccount -ResourceGroupName -Name |select-object -ExpandProperty encryption|format-list ``` Ensure the value of `KeyVaultProperties` is not `null` or empty, and ensure `KeySource` is not set to `Microsoft.Storage`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [fbb99e8e-e444-4da0-9ff1-75c92f5a85b2](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Ffbb99e8e-e444-4da0-9ff1-75c92f5a85b2) **- Name:** 'Storage account containing the container with activity logs must be encrypted with BYOK'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-5-use-customer-managed-key-option-in-data-at-rest-encryption-when-required:https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/activity-log?tabs=cli#managing-legacy-log-profiles", + "DefaultValue": "By default, for a storage account `keySource` is set to `Microsoft.Storage` allowing encryption with vendor Managed key and not a Customer Managed Key." + } + ] + }, + { + "Id": "6.1.1.4", + "Description": "Ensure that Logging for Azure Key Vault is 'Enabled'", + "Checks": [ + "keyvault_logging_enabled" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Logging for Azure Key Vault is 'Enabled'", + "RationaleStatement": "Monitoring how and when key vaults are accessed, and by whom, enables an audit trail of interactions with confidential information, keys, and certificates managed by Azure Key Vault. Enabling logging for Key Vault saves information in a user provided destination of either an Azure storage account or Log Analytics workspace. The same destination can be used for collecting logs for multiple Key Vaults.", + "ImpactStatement": "Enabling logging incurs costs based on the volume of logs generated.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Key vaults`. 2. Select a Key vault. 3. Under `Monitoring`, select `Diagnostic settings`. 4. Click `Edit setting` to update an existing diagnostic setting, or `Add diagnostic setting` to create a new one. 5. If creating a new diagnostic setting, provide a name. 6. Configure an appropriate destination. 7. Under `Category groups`, check `audit` and `allLogs`. 8. Click `Save`. **Remediate from Azure CLI** To update an existing `Diagnostic Settings` ``` az monitor diagnostic-settings update --name --resource ``` To create a new `Diagnostic Settings` ``` az monitor diagnostic-settings create --name --resource --logs [{category:audit,enabled:true},{category:allLogs,enabled:true}] --metrics [{category:AllMetrics,enabled:true}] <[--event-hub --event-hub-rule | --storage-account |--workspace | --marketplace-partner-id ]> ``` **Remediate from PowerShell** Create the `Log` settings object ``` $logSettings = @() $logSettings += New-AzDiagnosticSettingLogSettingsObject -Enabled $true -Category audit $logSettings += New-AzDiagnosticSettingLogSettingsObject -Enabled $true -Category allLogs ``` Create the `Metric` settings object ``` $metricSettings = @() $metricSettings += New-AzDiagnosticSettingMetricSettingsObject -Enabled $true -Category AllMetrics ``` Create the `Diagnostic Settings` for each `Key Vault` ``` New-AzDiagnosticSetting -Name -ResourceId -Log $logSettings -Metric $metricSettings [-StorageAccountId | -EventHubName -EventHubAuthorizationRuleId | -WorkSpaceId | -MarketPlacePartnerId ] ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Key vaults`. 1. For each Key vault, under `Monitoring`, go to `Diagnostic settings`. 1. Click `Edit setting` next to a diagnostic setting. 1. Ensure that a destination is configured. 1. Under `Category groups`, ensure that `audit` and `allLogs` are checked. **Audit from Azure CLI** List all key vaults ``` az keyvault list ``` For each keyvault `id` ``` az monitor diagnostic-settings list --resource ``` Ensure that `storageAccountId` reflects your desired destination and that `categoryGroup` and `enabled` are set as follows in the sample outputs below. ``` logs: [ { categoryGroup: audit, enabled: true, }, { categoryGroup: allLogs, enabled: true, } ``` **Audit from PowerShell** List the key vault(s) in the subscription ``` Get-AzKeyVault ``` For each key vault, run the following: ``` Get-AzDiagnosticSetting -ResourceId ``` Ensure that `StorageAccountId`, `ServiceBusRuleId`, `MarketplacePartnerId`, or `WorkspaceId` is set as appropriate. Also, ensure that `enabled` is set to `true`, and that `categoryGroup` reflects both `audit` and `allLogs` category groups. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [cf820ca0-f99e-4f3e-84fb-66e913812d21](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fcf820ca0-f99e-4f3e-84fb-66e913812d21) **- Name:** 'Resource logs in Key Vault should be enabled'", + "AdditionalInformation": "**DEPRECATION WARNING** Retention rules for Key Vault logging is being migrated to Azure Storage Lifecycle Management. Retention rules should be set based on the needs of your organization and security or compliance frameworks. Please visit [https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/migrate-to-azure-storage-lifecycle-policy?tabs=portal](https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/migrate-to-azure-storage-lifecycle-policy?tabs=portal) for detail on migrating your retention rules. Microsoft has provided the following deprecation timeline: March 31, 2023 – The Diagnostic Settings Storage Retention feature will no longer be available to configure new retention rules for log data. This includes using the portal, CLI PowerShell, and ARM and Bicep templates. If you have configured retention settings, you'll still be able to see and change them in the portal. March 31, 2024 – You will no longer be able to use the API (CLI, Powershell, or templates), or Azure portal to configure retention setting unless you're changing them to 0. Existing retention rules will still be respected. September 30, 2025 – All retention functionality for the Diagnostic Settings Storage Retention feature will be disabled across all environments.", + "References": "https://docs.microsoft.com/en-us/azure/key-vault/general/howto-logging:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-8-ensure-security-of-key-and-certificate-repository:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, Diagnostic AuditEvent logging is not enabled for Key Vault instances." + } + ] + }, + { + "Id": "6.1.1.5", + "Description": "Ensure that Network Security Group Flow Logs are Captured and Sent to Log Analytics", + "Checks": [ + "network_flow_log_captured_sent" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that Network Security Group Flow Logs are Captured and Sent to Log Analytics", + "RationaleStatement": "Network Flow Logs provide valuable insight into the flow of traffic around your network and feed into both Azure Monitor and Azure Sentinel (if in use), permitting the generation of visual flow diagrams to aid with analyzing for lateral movement, etc.", + "ImpactStatement": "The impact of configuring NSG Flow logs is primarily one of cost and configuration. If deployed, it will create storage accounts that hold minimal amounts of data on a 5-day lifecycle before feeding to Log Analytics Workspace. This will increase the amount of data stored and used by Azure Monitor.", + "RemediationProcedure": "**Remediate from Azure Portal** Existing NSG flow logs can still be reviewed under `Network Watcher` > `Flow logs`. If you already have NSG flow logs configured, ensure they remain enabled and that `Traffic Analytics` sends data to a `Log Analytics Workspace` until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, create `Virtual network` flow logs instead: 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Select `+ Create`. 1. Select the desired Subscription. 1. For `Flow log type`, select `Virtual network`. 1. Select `+ Select target resource`. 1. Select `Virtual network`. 1. Select a virtual network. 1. Click `Confirm selection`. 1. Select or create a new Storage Account. 1. If using a v2 storage account, input the retention in days to retain the log. 1. Click `Next`. 1. Under `Analytics`, for `Flow log version`, select `Version 2`. 1. Check the box next to `Enable traffic analytics`. 1. Select a processing interval. 1. Select a `Log Analytics Workspace`. 1. Select `Next`. 1. Optionally add Tags. 1. Select `Review + create`. 1. Select `Create`.", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down, select `Flow log type`. 1. Review existing `Network security group` flow logs, if any remain, to ensure they are enabled and configured to send logs to a `Log Analytics Workspace`. 1. Review `Virtual network` flow logs for new or migrated coverage. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [27960feb-a23c-4577-8d36-ef8b5f35e0be](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F27960feb-a23c-4577-8d36-ef8b5f35e0be) **- Name:** 'All flow log resources should be in enabled state' - **Policy ID:** [c251913d-7d24-4958-af87-478ed3b9ba41](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc251913d-7d24-4958-af87-478ed3b9ba41) **- Name:** 'Flow logs should be configured for every network security group' - **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Flow logs should be configured for every virtual network'", + "AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies. For details, see the official announcement: https://azure.microsoft.com/en-gb/updates?id=Azure-NSG-flow-logs-Retirement.", + "References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-portal:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-4-enable-network-logging-for-security-investigation", + "DefaultValue": "By default Network Security Group logs are not sent to Log Analytics." + } + ] + }, + { + "Id": "6.1.1.6", + "Description": "Ensure that Virtual Network Flow Logs are Captured and Sent to Log Analytics", + "Checks": [ + "network_flow_log_captured_sent" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that Virtual Network Flow Logs are Captured and Sent to Log Analytics", + "RationaleStatement": "Virtual network flow logs provide critical visibility into traffic patterns. Sending logs to a Log Analytics workspace enables centralized analysis, correlation, and alerting for faster threat detection and response.", + "ImpactStatement": "* Virtual network flow logs are charged per gigabyte of network flow logs collected and come with a free tier of 5 GB/month per subscription. * If traffic analytics is enabled with virtual network flow logs, traffic analytics pricing applies at per gigabyte processing rates. * The storage of logs is charged separately.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Network Watcher`. 1. Under `Logs`, click `Flow logs`. 1. Click `+ Create`. 1. Select a subscription. 1. Next to `Flow log type`, select `Virtual network`. 1. Click `+ Select target resource`. 1. Select `Virtual network`. 1. Select a virtual network. 1. Click `Confirm selection`. 1. Select a storage account, or create a new storage account. 1. Set the retention in days for the storage account. 1. Click `Next`. 1. Under `Analytics`, for `Flow logs version`, select `Version 2`. 1. Check the box next to `Enable traffic analytics`. 1. Select a processing interval. 1. Select a `Log Analytics Workspace`. 1. Click `Next`. 1. Optionally, add `Tags`. 1. Click `Review + create`. 1. Click `Create`. 1. Repeat steps 1-20 for each subscription or virtual network requiring remediation.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down menu, select `Flow log type`. 1. From the `Value` drop-down menu, check `Virtual network` only. 1. Click `Apply`. 1. Ensure that at least one virtual network flow log is listed and is configured to send logs to a `Log Analytics Workspace`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [2f080164-9f4d-497e-9db6-416dc9f7b48a](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F2f080164-9f4d-497e-9db6-416dc9f7b48a) **- Name:** 'Network Watcher flow logs should have traffic analytics enabled' - **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Audit flow logs configuration for every virtual network'", + "AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs will no longer be possible after June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies. For details, see the official announcement: https://azure.microsoft.com/en-gb/updates?id=Azure-NSG-flow-logs-Retirement.", + "References": "https://learn.microsoft.com/en-us/azure/network-watcher/vnet-flow-logs-overview:https://learn.microsoft.com/en-us/azure/network-watcher/vnet-flow-logs-cli", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.1.1.7", + "Description": "Ensure that a Microsoft Entra Diagnostic Setting Exists to Send Microsoft Graph Activity Logs to an Appropriate Destination", + "Checks": [ + "monitor_diagnostic_settings_exists" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that a Microsoft Entra Diagnostic Setting Exists to Send Microsoft Graph Activity Logs to an Appropriate Destination", + "RationaleStatement": "Microsoft Graph activity logs provide visibility into HTTP requests made to the Microsoft Graph service, helping detect unauthorized access, suspicious activity, and security threats. Configuring diagnostic settings in Microsoft Entra ensures these logs are collected and sent to an appropriate destination for monitoring, analysis, and retention.", + "ImpactStatement": "A Microsoft Entra ID P1 or P2 tenant license is required to access the Microsoft Graph activity logs. The amount of data logged and, thus, the cost incurred can vary significantly depending on the tenant size and the applications in your tenant that interact with the Microsoft Graph APIs. See the following pricing calculations for respective services: - Log Analytics: https://learn.microsoft.com/en-us/azure/azure-monitor/logs/cost-logs#pricing-model. - Azure Storage: https://azure.microsoft.com/en-gb/pricing/details/storage/blobs/. - Event Hubs: https://azure.microsoft.com/en-gb/pricing/details/event-hubs/", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Entra ID`. 1. Under `Monitoring`, click `Diagnostic settings`. 1. Click `+ Add diagnostic setting`. 1. Provide a `Diagnostic setting name`. 1. Under `Logs > Categories`, check the box next to `MicrosoftGraphActivityLogs`. 1. Configure an appropriate destination for the logs. 1. Click `Save`.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Entra ID`. 1. Under `Monitoring`, click `Diagnostic settings`. 1. Next to each diagnostic setting, click `Edit setting`, and review the selected log categories and destination details. 1. Ensure that at least one diagnostic setting is configured to send `MicrosoftGraphActivityLogs` to an appropriate destination.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/entra/identity/monitoring-health/howto-configure-diagnostic-settings:https://learn.microsoft.com/en-us/graph/microsoft-graph-activity-logs-overview:https://learn.microsoft.com/en-us/azure/azure-monitor/logs/cost-logs#pricing-model:https://azure.microsoft.com/en-gb/pricing/details/storage/blobs/:https://azure.microsoft.com/en-gb/pricing/details/event-hubs/", + "DefaultValue": "By default, Microsoft Entra diagnostic settings do not exist." + } + ] + }, + { + "Id": "6.1.1.8", + "Description": "Ensure that a Microsoft Entra Diagnostic Setting Exists to Send Microsoft Entra Activity Logs to an Appropriate Destination", + "Checks": [ + "monitor_diagnostic_settings_exists" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that a Microsoft Entra Diagnostic Setting Exists to Send Microsoft Entra Activity Logs to an Appropriate Destination", + "RationaleStatement": "Microsoft Entra activity logs enables you to assess many aspects of your Microsoft Entra tenant. Configuring diagnostic settings in Microsoft Entra ensures these logs are collected and sent to an appropriate destination for monitoring, analysis, and retention.", + "ImpactStatement": "To export sign-in data, your organization needs an Azure AD P1 or P2 license. The amount of data logged and, thus, the cost incurred can vary significantly depending on the tenant size. See the following pricing calculations for respective services: - Log Analytics: https://learn.microsoft.com/en-us/azure/azure-monitor/logs/cost-logs#pricing-model. - Azure Storage: https://azure.microsoft.com/en-gb/pricing/details/storage/blobs/. - Event Hubs: https://azure.microsoft.com/en-gb/pricing/details/event-hubs/", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Entra ID`. 1. Under `Monitoring`, click `Diagnostic settings`. 1. Click `+ Add diagnostic setting`. 1. Provide a `Diagnostic setting name`. 1. Under `Logs > Categories`, check the box next to each of the following logs: - `AuditLogs` - `SignInLogs` - `NonInteractiveUserSignInLogs` - `ServicePrincipalSignInLogs` - `ManagedIdentitySignInLogs` - `ProvisioningLogs` - `ADFSSignInLogs` - `RiskyUsers` - `UserRiskEvents` - `NetworkAccessTrafficLogs` - `RiskyServicePrincipals` - `ServicePrincipalRiskEvents` - `EnrichedOffice365AuditLogs` - `MicrosoftGraphActivityLogs` - `RemoteNetworkHealthLogs` - `NetworkAccessAlerts` 1. Configure an appropriate destination for the logs. 1. Click `Save`.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Entra ID`. 1. Under `Monitoring`, click `Diagnostic settings`. 1. Next to each diagnostic setting, click `Edit setting`, and review the selected log categories and destination details. 1. Ensure that at least one diagnostic setting is configured to send the following logs to an appropriate destination: - `AuditLogs` - `SignInLogs` - `NonInteractiveUserSignInLogs` - `ServicePrincipalSignInLogs` - `ManagedIdentitySignInLogs` - `ProvisioningLogs` - `ADFSSignInLogs` - `RiskyUsers` - `UserRiskEvents` - `NetworkAccessTrafficLogs` - `RiskyServicePrincipals` - `ServicePrincipalRiskEvents` - `EnrichedOffice365AuditLogs` - `MicrosoftGraphActivityLogs` - `RemoteNetworkHealthLogs` - `NetworkAccessAlerts`", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/entra/identity/monitoring-health/howto-configure-diagnostic-settings:https://learn.microsoft.com/en-us/entra/identity/monitoring-health/howto-access-activity-logs?tabs=microsoft-entra-activity-logs%2Carchive-activity-logs-to-a-storage-account", + "DefaultValue": "By default, Microsoft Entra diagnostic settings do not exist." + } + ] + }, + { + "Id": "6.1.1.9", + "Description": "Ensure that Intune Logs are Captured and Sent to Log Analytics", + "Checks": [], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that Intune Logs are Captured and Sent to Log Analytics", + "RationaleStatement": "Intune includes built-in logs that provide information about your environments. Sending logs to a Log Analytics workspace enables centralized analysis, correlation, and alerting for faster threat detection and response.", + "ImpactStatement": "A Microsoft Intune plan is required to access Intune: https://www.microsoft.com/en-gb/security/business/microsoft-intune-pricing. The amount of data logged and, thus, the cost incurred can vary significantly depending on the tenant size. For information on Log Analytics workspace costs, visit: https://learn.microsoft.com/en-us/azure/azure-monitor/logs/cost-logs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Intune`. 1. Click `Reports`. 1. Under `Azure monitor`, click `Diagnostic settings`. 1. Click `+ Add diagnostic setting`. 1. Provide a `Diagnostic setting name`. 1. Under `Logs > Categories`, check the box next to each of the following logs: - `AuditLogs` - `OperationalLogs` - `DeviceComplianceOrg` - `Devices` - `Windows365AuditLogs` 1. Under `Destination details`, check the box next to `Send to Log Analytics workspace`. 1. Select a `Subscription`. 1. Select a `Log Analytics workspace`. 1. Click `Save`.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Intune`. 1. Click `Reports`. 1. Under `Azure monitor`, click `Diagnostic settings`. 1. Next to each diagnostic setting, click `Edit setting`, and review the selected log categories and destination details. 1. Ensure that at least one diagnostic setting is configured to send the following logs to a Log Analytics workspace: - `AuditLogs` - `OperationalLogs` - `DeviceComplianceOrg` - `Devices` - `Windows365AuditLogs`", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/mem/intune/fundamentals/review-logs-using-azure-monitor:https://www.microsoft.com/en-gb/security/business/microsoft-intune-pricing:https://learn.microsoft.com/en-us/azure/azure-monitor/logs/cost-logs", + "DefaultValue": "By default, Intune diagnostic settings do not exist." + } + ] + }, + { + "Id": "6.1.2.1", + "Description": "Ensure that Activity Log Alert Exists for Create Policy Assignment", + "Checks": [ + "monitor_alert_create_policy_assignment" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert Exists for Create Policy Assignment", + "RationaleStatement": "Monitoring for create policy assignment events gives insight into changes done in Azure policy - assignments and can reduce the time it takes to detect unsolicited changes.", + "ImpactStatement": "Alert rules incur minimal costs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Create policy assignment (Policy assignment)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Authorization/policyAssignments/write and level= --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the `conditions` object. ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Authorization/policyAssignments/write -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Get the `Action Group` information and store it in a variable, then create a new `Action` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` variable. ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Authorization/policyAssignments/write` ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Authorization/policyAssignments/write`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Create policy assignment'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Authorization/policyAssignments/write` in the output. If it's missing, generate a finding. **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Authorization/policyAssignments/write}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` If the output is empty, an `alert rule` for `Create Policy Assignments` is not configured. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [c5447c04-a4d7-4ba8-a263-c9ee321a6858](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc5447c04-a4d7-4ba8-a263-c9ee321a6858) **- Name:** 'An activity log alert should exist for specific Policy operations'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement:https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation:https://docs.microsoft.com/en-in/rest/api/policy/policy-assignments:https://docs.microsoft.com/en-us/azure/azure-monitor/alerts/alerts-log", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.2", + "Description": "Ensure that Activity Log Alert exists for Delete Policy Assignment", + "Checks": [ + "monitor_alert_delete_policy_assignment" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert exists for Delete Policy Assignment", + "RationaleStatement": "Monitoring for delete policy assignment events gives insight into changes done in azure policy - assignments and can reduce the time it takes to detect unsolicited changes.", + "ImpactStatement": "Alert rules incur minimal costs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Delete policy assignment (Policy assignment)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Authorization/policyAssignments/delete and level= --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the conditions object ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Authorization/policyAssignments/delete -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Retrieve the `Action Group` information and store in a variable, then create the `Action` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` variable. ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Authorization/policyAssignments/delete`. ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Authorization/policyAssignments/delete`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Delete policy assignment'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Authorization/policyAssignments/delete` in the output **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Authorization/policyAssignments/delete}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [c5447c04-a4d7-4ba8-a263-c9ee321a6858](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc5447c04-a4d7-4ba8-a263-c9ee321a6858) **- Name:** 'An activity log alert should exist for specific Policy operations'", + "AdditionalInformation": "This log alert also applies for Azure Blueprints.", + "References": "https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation:https://azure.microsoft.com/en-us/services/blueprints/", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.3", + "Description": "Ensure that Activity Log Alert Exists for Create or Update Network Security Group", + "Checks": [ + "monitor_alert_create_update_nsg" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert Exists for Create or Update Network Security Group", + "RationaleStatement": "Monitoring for Create or Update Network Security Group events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "Alert rules incur minimal costs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Create or Update Network Security Group (Network Security Group)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Network/networkSecurityGroups/write and level=verbose --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the `Conditions` object. ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Network/networkSecurityGroups/write -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Retrieve the `Action Group` information and store in a variable, then create the `Actions` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` object ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Network/networkSecurityGroups/write` ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Network/networkSecurityGroups/write`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Create or Update Network Security Group'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Network/networkSecurityGroups/write` in the output **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Network/networkSecurityGroups/write}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [b954148f-4c11-4c38-8221-be76711e194a](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb954148f-4c11-4c38-8221-be76711e194a) **- Name:** 'An activity log alert should exist for specific Administrative operations'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement:https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.4", + "Description": "Ensure that Activity Log Alert Exists for Delete Network Security Group", + "Checks": [ + "monitor_alert_delete_nsg" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert Exists for Delete Network Security Group", + "RationaleStatement": "Monitoring for Delete Network Security Group events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "Alert rules incur minimal costs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Delete Network Security Group (Network Security Group)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Network/networkSecurityGroups/delete and level= --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the `Conditions` object. ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Network/networkSecurityGroups/delete -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Retrieve the `Action Group` information and store in a variable, then create the `Actions` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` object ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Network/networkSecurityGroups/delete` ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Network/networkSecurityGroups/delete`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Delete Network Security Group'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Network/networkSecurityGroups/delete` in the output **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Network/networkSecurityGroups/delete}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [b954148f-4c11-4c38-8221-be76711e194a](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb954148f-4c11-4c38-8221-be76711e194a) **- Name:** 'An activity log alert should exist for specific Administrative operations'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement:https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.5", + "Description": "Ensure that Activity Log Alert Exists for Create or Update Security Solution", + "Checks": [ + "monitor_alert_create_update_security_solution" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert Exists for Create or Update Security Solution", + "RationaleStatement": "Monitoring for Create or Update Security Solution events gives insight into changes to the active security solutions and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "Alert rules incur minimal costs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Create or Update Security Solutions (Security Solutions)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Security/securitySolutions/write and level= --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the `Conditions` object. ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Security/securitySolutions/write -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Retrieve the `Action Group` information and store in a variable, then create the `Actions` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` object ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Security/securitySolutions/write` ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Security/securitySolutions/write`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Create or Update Security Solutions'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Security/securitySolutions/write` in the output **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Security/securitySolutions/write}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [b954148f-4c11-4c38-8221-be76711e194a](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb954148f-4c11-4c38-8221-be76711e194a) **- Name:** 'An activity log alert should exist for specific Administrative operations'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement:https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.6", + "Description": "Ensure that Activity Log Alert Exists for Delete Security Solution", + "Checks": [ + "monitor_alert_delete_security_solution" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert Exists for Delete Security Solution", + "RationaleStatement": "Monitoring for Delete Security Solution events gives insight into changes to the active security solutions and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "Alert rules incur minimal costs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Delete Security Solutions (Security Solutions)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Security/securitySolutions/delete and level= --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the `Conditions` object. ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Security/securitySolutions/delete -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Retrieve the `Action Group` information and store in a variable, then create the `Actions` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` object ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Security/securitySolutions/delete` ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Security/securitySolutions/delete`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Delete Security Solutions'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Security/securitySolutions/delete` in the output **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Security/securitySolutions/delete}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [b954148f-4c11-4c38-8221-be76711e194a](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb954148f-4c11-4c38-8221-be76711e194a) **- Name:** 'An activity log alert should exist for specific Administrative operations'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement:https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.7", + "Description": "Ensure that Activity Log Alert Exists for Create or Update SQL Server Firewall Rule", + "Checks": [ + "monitor_alert_create_update_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert Exists for Create or Update SQL Server Firewall Rule", + "RationaleStatement": "Monitoring for Create or Update SQL Server Firewall Rule events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "There will be a substantial increase in log size if there are a large number of administrative actions on a server.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Create/Update server firewall rule (Server Firewall Rule)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Sql/servers/firewallRules/write and level= --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the `Conditions` object. ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Sql/servers/firewallRules/write -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Retrieve the `Action Group` information and store in a variable, then create the `Actions` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` object ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Sql/servers/firewallRules/write` ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Sql/servers/firewallRules/write`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Create/Update server firewall rule'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Sql/servers/firewallRules/write` in the output **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Sql/servers/firewallRules/write}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [b954148f-4c11-4c38-8221-be76711e194a](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb954148f-4c11-4c38-8221-be76711e194a) **- Name:** 'An activity log alert should exist for specific Administrative operations'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement:https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.8", + "Description": "Ensure that Activity Log Alert Exists for Delete SQL Server Firewall Rule", + "Checks": [ + "monitor_alert_delete_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert Exists for Delete SQL Server Firewall Rule", + "RationaleStatement": "Monitoring for Delete SQL Server Firewall Rule events gives insight into SQL network access changes and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "There will be a substantial increase in log size if there are a large number of administrative actions on a server.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Delete server firewall rule (Server Firewall Rule)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Sql/servers/firewallRules/delete and level= --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the `Conditions` object. ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Sql/servers/firewallRules/delete -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Retrieve the `Action Group` information and store in a variable, then create the `Actions` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` object ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Sql/servers/firewallRules/delete` ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Sql/servers/firewallRules/delete`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Delete server firewall rule'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Sql/servers/firewallRules/delete` in the output **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Sql/servers/firewallRules/delete}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [b954148f-4c11-4c38-8221-be76711e194a](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb954148f-4c11-4c38-8221-be76711e194a) **- Name:** 'An activity log alert should exist for specific Administrative operations'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement:https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.9", + "Description": "Ensure that Activity Log Alert Exists for Create or Update Public IP Address rule", + "Checks": [ + "monitor_alert_create_update_public_ip_address_rule" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert Exists for Create or Update Public IP Address rule", + "RationaleStatement": "Monitoring for Create or Update Public IP Address events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "There will be a substantial increase in log size if there are a large number of administrative actions on a server.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Create or Update Public Ip Address (Public Ip Address)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Network/publicIPAddresses/write and level= --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the `Conditions` object. ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Network/publicIPAddresses/write -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Retrieve the `Action Group` information and store in a variable, then create the `Actions` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` object ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Network/publicIPAddresses/write` ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Network/publicIPAddresses/write`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Create or Update Public Ip Address'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Network/publicIPAddresses/write` in the output **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Network/publicIPAddresses/write}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [1513498c-3091-461a-b321-e9b433218d28](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F1513498c-3091-461a-b321-e9b433218d28) **- Name:** 'Enable logging by category group for Public IP addresses (microsoft.network/publicipaddresses) to Log Analytics'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement:https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.10", + "Description": "Ensure that Activity Log Alert Exists for Delete Public IP Address rule", + "Checks": [ + "monitor_alert_delete_public_ip_address_rule" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert Exists for Delete Public IP Address rule", + "RationaleStatement": "Monitoring for Delete Public IP Address events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "There will be a substantial increase in log size if there are a large number of administrative actions on a server.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Delete Public Ip Address (Public Ip Address)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Network/publicIPAddresses/delete and level= --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the `Conditions` object. ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Network/publicIPAddresses/delete -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Retrieve the `Action Group` information and store in a variable, then create the `Actions` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` object ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Network/publicIPAddresses/delete` ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Network/publicIPAddresses/delete`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Delete Public Ip Address'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Network/publicIPAddresses/delete` in the output **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Network/publicIPAddresses/delete}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [1513498c-3091-461a-b321-e9b433218d28](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F1513498c-3091-461a-b321-e9b433218d28) **- Name:** 'Enable logging by category group for Public IP addresses (microsoft.network/publicipaddresses) to Log Analytics'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement:https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.11", + "Description": "Ensure that an Activity Log Alert Exists for Service Health", + "Checks": [ + "monitor_alert_service_health_exists" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that an Activity Log Alert Exists for Service Health", + "RationaleStatement": "Monitoring for Service Health events provides insight into service issues, planned maintenance, security advisories, and other changes that may affect the Azure services and regions in use.", + "ImpactStatement": "There is no charge for creating activity log alert rules.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Monitor`. 1. Click `Alerts`. 1. Click `+ Create`. 1. Select `Alert rule` from the drop-down menu. 1. Choose a subscription. 1. Click `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Service health`. 1. Click `Apply`. 1. Open the drop-down menu next to `Event types`. 1. Check the box next to `Select all`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. 1. Repeat steps 1-19 for each subscription requiring remediation. **Remediate from Azure CLI** For each subscription requiring remediation, run the following command to create a `ServiceHealth` alert rule for a subscription: ``` az monitor activity-log alert create --subscription --resource-group --name --condition category=ServiceHealth and properties.incidentType=Incident --scope /subscriptions/ --action-group ``` **Remediate from PowerShell** Create the `Conditions` object: ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Field category -Equal ServiceHealth $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Field properties.incidentType -Equal Incident ``` Retrieve the `Action Group` information and store in a variable: ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name ``` Create the `Actions` object: ``` $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` object: ``` $scope = /subscriptions/ ``` Create the activity log alert rule: ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ``` Repeat for each subscription requiring remediation.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Monitor`. 1. Click `Alerts`. 1. Click `Alert rules`. 1. Ensure an alert rule exists for a subscription with `Condition` set to `Service names=All, Event types=All` and `Target resource type` set to `Subscription`. 1. If an alert rule is found for step 4, click the name of the alert rule. 1. Ensure the `Actions` panel displays an action group configured to notify appropriate personnel. 1. Repeat steps 1-6 for each subscription. **Audit from Azure CLI** Run the following command to list activity log alerts: ``` az monitor activity-log alert list --subscription ``` For each activity log alert, run the following command: ``` az monitor activity-log alert show --subscription --resource-group --activity-log-alert-name ``` Ensure an alert exists for `ServiceHealth` with `scopes` set to a subscription ID. Repeat for each subscription. **Audit from PowerShell** Run the following command to locate `ServiceHealth` alert rules for a subscription: ``` Get-AzActivityLogAlert -SubscriptionId | where-object {$_.ConditionAllOf.Equal -match ServiceHealth} | select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` Ensure that at least one `ServiceHealth` alert rule is returned. Repeat for each subscription.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/service-health/overview:https://learn.microsoft.com/en-us/azure/service-health/alerts-activity-log-service-notifications-portal:https://azure.microsoft.com/en-gb/pricing/details/monitor/#faq:https://learn.microsoft.com/en-us/cli/azure/monitor/activity-log/alert:https://learn.microsoft.com/en-us/powershell/module/az.monitor/get-azactivitylogalert:https://learn.microsoft.com/en-us/powershell/module/az.monitor/new-azactivitylogalert", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.3.1", + "Description": "Ensure Application Insights are Configured", + "Checks": [ + "appinsights_ensure_is_configured" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Application Insights are Configured", + "RationaleStatement": "Configuring Application Insights provides additional data not found elsewhere within Azure as part of a much larger logging and monitoring program within an organization's Information Security practice. The types and contents of these logs will act as both a potential cost saving measure (application performance) and a means to potentially confirm the source of a potential incident (trace logging). Metrics and Telemetry data provide organizations with a proactive approach to cost savings by monitoring an application's performance, while the trace logging data provides necessary details in a reactive incident response scenario by helping organizations identify the potential source of an incident within their application.", + "ImpactStatement": "Because Application Insights relies on a Log Analytics Workspace, an organization will incur additional expenses when using this service.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to `Application Insights`. 2. Under the `Basics` tab within the `PROJECT DETAILS` section, select the `Subscription`. 3. Select the `Resource group`. 4. Within the `INSTANCE DETAILS`, enter a `Name`. 5. Select a `Region`. 6. Next to `Resource Mode`, select `Workspace-based`. 7. Within the `WORKSPACE DETAILS`, select the `Subscription` for the log analytics workspace. 8. Select the appropriate `Log Analytics Workspace`. 9. Click `Next:Tags >`. 10. Enter the appropriate `Tags` as `Name`, `Value` pairs. 11. Click `Next:Review+Create`. 12. Click `Create`. **Remediate from Azure CLI** ``` az monitor app-insights component create --app --resource-group --location --kind web --retention-time --workspace --subscription ``` **Remediate from PowerShell** ``` New-AzApplicationInsights -Kind web -ResourceGroupName -Name -location -RetentionInDays -SubscriptionID -WorkspaceResourceId ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to `Application Insights`. 2. Ensure an `Application Insights` service is configured and exists. **Audit from Azure CLI** ``` az monitor app-insights component show --query [].{ID:appId, Name:name, Tenant:tenantId, Location:location, Provisioning_State:provisioningState} ``` Ensure the above command produces output, otherwise `Application Insights` has not been configured. **Audit from PowerShell** ``` Get-AzApplicationInsights|select location,name,appid,provisioningState,tenantid ```", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview", + "DefaultValue": "Application Insights are not enabled by default." + } + ] + }, + { + "Id": "6.1.4", + "Description": "Ensure that Azure Monitor Resource Logging is Enabled for All Services that Support it", + "Checks": [ + "monitor_diagnostic_settings_exists" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that Azure Monitor Resource Logging is Enabled for All Services that Support it", + "RationaleStatement": "A lack of monitoring reduces the visibility into the data plane, and therefore an organization's ability to detect reconnaissance, authorization attempts or other malicious activity. Unlike Activity Logs, Resource Logs are not enabled by default. Specifically, without monitoring it would be impossible to tell which entities had accessed a data store that was breached. In addition, alerts for failed attempts to access APIs for Web Services or Databases are only possible when logging is enabled.", + "ImpactStatement": "Costs for monitoring varies with Log Volume. Not every resource needs to have logging enabled. It is important to determine the security classification of the data being processed by the given resource and adjust the logging based on which events need to be tracked. This is typically determined by governance and compliance requirements.", + "RemediationProcedure": "Azure Subscriptions should log every access and operation for all resources. Logs should be sent to Storage and a Log Analytics Workspace or equivalent third-party system. Logs should be kept in readily-accessible storage for a minimum of one year, and then moved to inexpensive cold storage for a duration of time as necessary. If retention policies are set but storing logs in a Storage Account is disabled (for example, if only Event Hubs or Log Analytics options are selected), the retention policies have no effect. Enable all monitoring at first, and then be more aggressive moving data to cold storage if the volume of data becomes a cost concern. **Remediate from Azure Portal** The specific steps for configuring resources within the Azure console vary depending on resource, but typically the steps are: 1. Go to the resource 2. Click on Diagnostic settings 3. In the blade that appears, click Add diagnostic setting 4. Configure the diagnostic settings 5. Click on Save **Remediate from Azure CLI** For each `resource`, run the following making sure to use a `resource` appropriate JSON encoded `category` for the `--logs` option. ``` az monitor diagnostic-settings create --name --resource --logs [{category:,enabled:true,rentention-policy:{enabled:true,days:180}}] --metrics [{category:AllMetrics,enabled:true,retention-policy:{enabled:true,days:180}}] <[--event-hub --event-hub-rule | --storage-account |--workspace | --marketplace-partner-id ]> ``` **Remediate from PowerShell** Create the `log` settings object ``` $logSettings = @() $logSettings += New-AzDiagnosticSettingLogSettingsObject -Enabled $true -RetentionPolicyDay 180 -RetentionPolicyEnabled $true -Category $logSettings += New-AzDiagnosticSettingLogSettingsObject -Enabled $true -RetentionPolicyDay 180 -RetentionPolicyEnabled $true -Category ``` Create the `metric` settings object ``` $metricSettings = @() $metricSettings += New-AzDiagnosticSettingMetricSettingsObject -Enabled $true -RetentionPolicyDay 180 -RetentionPolicyEnabled $true -Category AllMetrics ``` Create the diagnostic setting for a specific resource ``` New-AzDiagnosticSetting -Name -ResourceId -Log $logSettings -Metric $metricSettings ```", + "AuditProcedure": "**Audit from Azure Portal** The specific steps for configuring resources within the Azure console vary depending on resource, but typically the steps are: 1. Go to the resource 2. Click on Diagnostic settings 3. In the blade that appears, click Add diagnostic setting 4. Configure the diagnostic settings 5. Click on Save **Audit from Azure CLI** List all `resources` for a `subscription` ``` az resource list --subscription ``` For each `resource` run the following ``` az monitor diagnostic-settings list --resource ``` An empty result means a `diagnostic settings` is not configured for that resource. An error message means a `diagnostic settings` is not supported for that resource. **Audit from PowerShell** Get a list of `resources` in a `subscription` context and store in a variable ``` $resources = Get-AzResource ``` Loop through each `resource` to determine if a diagnostic setting is configured or not. ``` foreach ($resource in $resources) {$diagnosticSetting = Get-AzDiagnosticSetting -ResourceId $resource.id -ErrorAction SilentlyContinue; if ([string]::IsNullOrEmpty($diagnosticSetting)) {$message = Diagnostic Settings not configured for resource: + $resource.Name;Write-Output $message}else{$diagnosticSetting}} ``` A result of `Diagnostic Settings not configured for resource: ` means a `diagnostic settings` is not configured for that resource. Otherwise, the output of the above command will show configured `Diagnostic Settings` for a resource. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [cf820ca0-f99e-4f3e-84fb-66e913812d21](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fcf820ca0-f99e-4f3e-84fb-66e913812d21) **- Name:** 'Resource logs in Key Vault should be enabled' - **Policy ID:** [91a78b24-f231-4a8a-8da9-02c35b2b6510](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F91a78b24-f231-4a8a-8da9-02c35b2b6510) **- Name:** 'App Service apps should have resource logs enabled' - **Policy ID:** [428256e6-1fac-4f48-a757-df34c2b3336d](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F428256e6-1fac-4f48-a757-df34c2b3336d) **- Name:** 'Resource logs in Batch accounts should be enabled' - **Policy ID:** [057ef27e-665e-4328-8ea3-04b3122bd9fb](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F057ef27e-665e-4328-8ea3-04b3122bd9fb) **- Name:** 'Resource logs in Azure Data Lake Store should be enabled' - **Policy ID:** [c95c74d9-38fe-4f0d-af86-0c7d626a315c](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc95c74d9-38fe-4f0d-af86-0c7d626a315c) **- Name:** 'Resource logs in Data Lake Analytics should be enabled' - **Policy ID:** [83a214f7-d01a-484b-91a9-ed54470c9a6a](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F83a214f7-d01a-484b-91a9-ed54470c9a6a) **- Name:** 'Resource logs in Event Hub should be enabled' - **Policy ID:** [383856f8-de7f-44a2-81fc-e5135b5c2aa4](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F383856f8-de7f-44a2-81fc-e5135b5c2aa4) **- Name:** 'Resource logs in IoT Hub should be enabled' - **Policy ID:** [34f95f76-5386-4de7-b824-0d8478470c9d](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F34f95f76-5386-4de7-b824-0d8478470c9d) **- Name:** 'Resource logs in Logic Apps should be enabled' - **Policy ID:** [b4330a05-a843-4bc8-bf9a-cacce50c67f4](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb4330a05-a843-4bc8-bf9a-cacce50c67f4) **- Name:** 'Resource logs in Search services should be enabled' - **Policy ID:** [f8d36e2f-389b-4ee4-898d-21aeb69a0f45](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Ff8d36e2f-389b-4ee4-898d-21aeb69a0f45) **- Name:** 'Resource logs in Service Bus should be enabled' - **Policy ID:** [f9be5368-9bf5-4b84-9e0a-7850da98bb46](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Ff9be5368-9bf5-4b84-9e0a-7850da98bb46) **- Name:** 'Resource logs in Azure Stream Analytics should be enabled'", + "AdditionalInformation": "For an up-to-date list of Azure resources which support Azure Monitor, refer to the Supported Log Categories reference.", + "References": "https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-5-centralize-security-log-management-and-analysis:https://docs.microsoft.com/en-us/azure/azure-monitor/essentials/monitor-azure-resource:Supported Log Categories: https://docs.microsoft.com/en-us/azure/azure-monitor/essentials/resource-logs-categories:Logs and Audit - Fundamentals: https://docs.microsoft.com/en-us/azure/security/fundamentals/log-audit:Collecting Logs: https://docs.microsoft.com/en-us/azure/azure-monitor/platform/collect-activity-logs:Key Vault Logging: https://docs.microsoft.com/en-us/azure/key-vault/key-vault-logging:Monitor Diagnostic Settings: https://docs.microsoft.com/en-us/cli/azure/monitor/diagnostic-settings?view=azure-cli-latest:Overview of Diagnostic Logs: https://docs.microsoft.com/en-us/azure/azure-monitor/platform/diagnostic-logs-overview:Supported Services for Diagnostic Logs: https://docs.microsoft.com/en-us/azure/azure-monitor/platform/diagnostic-logs-schema:Diagnostic Logs for CDNs: https://docs.microsoft.com/en-us/azure/cdn/cdn-azure-diagnostic-logs", + "DefaultValue": "By default, Azure Monitor Resource Logs are 'Disabled' for all resources." + } + ] + }, + { + "Id": "6.1.5", + "Description": "Ensure Basic, Free, and Consumption SKUs are not used on Production artifacts requiring monitoring and SLA", + "Checks": [], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure Basic, Free, and Consumption SKUs are not used on Production artifacts requiring monitoring and SLA", + "RationaleStatement": "Typically, production workloads need to be monitored and should have an SLA with Microsoft, using Basic SKUs for any deployed product will mean that that these capabilities do not exist. The following resource types should use standard SKUs as a minimum. - Public IP Addresses - Network Load Balancers - REDIS Cache - SQL PaaS Databases - VPN Gateways", + "ImpactStatement": "The impact of enforcing Standard SKU's is twofold 1) There will be a cost increase 2) The monitoring and service level agreements will be available and will support the production service. All resources should be either tagged or in separate Management Groups/Subscriptions", + "RemediationProcedure": "Each resource has its own process for upgrading from basic to standard SKUs that should be followed if required. - Public IP Address: https://learn.microsoft.com/en-us/azure/virtual-network/ip-services/public-ip-upgrade. - Basic Load Balancer: https://learn.microsoft.com/en-us/azure/load-balancer/load-balancer-basic-upgrade-guidance. - Azure Cache for Redis: https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-how-to-scale. - Azure SQL Database: https://learn.microsoft.com/en-us/azure/azure-sql/database/scale-resources. - VPN Gateway: https://learn.microsoft.com/en-us/azure/vpn-gateway/gateway-sku-resize.", + "AuditProcedure": "This needs to be audited by Azure Policy (one for each resource type) and denied for each artifact that is production. **Audit from Azure Portal** 1. Open `Azure Resource Graph Explorer` 1. Click `New query` 1. Paste the following into the query window: ``` Resources | where sku contains 'Basic' or sku contains 'consumption' | order by type ``` 4. Click `Run query` then evaluate the results in the results window. 5. Ensure that no production artifacts are returned. **Audit from Azure CLI** ``` az graph query -q Resources | where sku contains 'Basic' or sku contains 'consumption' | order by type ``` Alternatively, to filter on a specific resource group: ``` az graph query -q Resources | where resourceGroup == '' | where sku contains 'Basic' or sku contains 'consumption' | order by type ``` Ensure that no production artifacts are returned. **Audit from PowerShell** ``` Get-AzResource | ?{ $_.Sku -EQ Basic} ``` Ensure that no production artifacts are returned.", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/support/plans:https://azure.microsoft.com/en-us/support/plans/response/:https://learn.microsoft.com/en-us/azure/virtual-network/ip-services/public-ip-upgrade:https://learn.microsoft.com/en-us/azure/load-balancer/load-balancer-basic-upgrade-guidance:https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-how-to-scale:https://learn.microsoft.com/en-us/azure/azure-sql/database/scale-resources:https://learn.microsoft.com/en-us/azure/vpn-gateway/gateway-sku-resize", + "DefaultValue": "Policy should enforce standard SKUs for the following artifacts: - Public IP Addresses - Network Load Balancers - REDIS Cache - SQL PaaS Databases - VPN Gateways" + } + ] + }, + { + "Id": "6.2", + "Description": "Ensure that Resource Locks are set for Mission-Critical Azure Resources", + "Checks": [ + "iam_custom_role_has_permissions_to_administer_resource_locks" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that Resource Locks are set for Mission-Critical Azure Resources", + "RationaleStatement": "As an administrator, it may be necessary to lock a subscription, resource group, or resource to prevent other users in the organization from accidentally deleting or modifying critical resources. The lock level can be set to to `CanNotDelete` or `ReadOnly` to achieve this purpose. - `CanNotDelete` means authorized users can still read and modify a resource, but they cannot delete the resource. - `ReadOnly` means authorized users can read a resource, but they cannot delete or update the resource. Applying this lock is similar to restricting all authorized users to the permissions granted by the Reader role.", + "ImpactStatement": "There can be unintended outcomes of locking a resource. Applying a lock to a parent service will cause it to be inherited by all resources within. Conversely, applying a lock to a resource may not apply to connected storage, leaving it unlocked. Please see the documentation for further information.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the specific Azure Resource or Resource Group. 2. For each mission critical resource, click on `Locks`. 3. Click `Add`. 4. Give the lock a name and a description, then select the type, `Read-only` or `Delete` as appropriate. 5. Click OK. **Remediate from Azure CLI** To lock a resource, provide the name of the resource, its resource type, and its resource group name. ``` az lock create --name --lock-type --resource-group --resource-name --resource-type ``` **Remediate from PowerShell** ``` Get-AzResourceLock -ResourceName -ResourceType -ResourceGroupName -Locktype ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the specific Azure Resource or Resource Group. 2. Click on `Locks`. 3. Ensure the lock is defined with name and description, with type `Read-only` or `Delete` as appropriate. **Audit from Azure CLI** Review the list of all locks set currently: ``` az lock list --resource-group --resource-name --namespace --resource-type --parent ``` **Audit from PowerShell** Run the following command to list all resources. ``` Get-AzResource ``` For each resource, run the following command to check for Resource Locks. ``` Get-AzResourceLock -ResourceName -ResourceType -ResourceGroupName ``` Review the output of the `Properties` setting. Compliant settings will have the `CanNotDelete` or `ReadOnly` value.", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-lock-resources:https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-manager-subscription-governance#azure-resource-locks:https://docs.microsoft.com/en-us/azure/governance/blueprints/concepts/resource-locking:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-asset-management#am-4-limit-access-to-asset-management", + "DefaultValue": "By default, no locks are set." + } + ] + }, + { + "Id": "7.1", + "Description": "Ensure that RDP Access from the Internet is Evaluated and Restricted", + "Checks": [ + "network_rdp_internet_access_restricted" + ], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that RDP Access from the Internet is Evaluated and Restricted", + "RationaleStatement": "The potential security problem with using RDP over the Internet is that attackers can use various brute force techniques to gain access to Azure Virtual Machines. Once the attackers gain access, they can use a virtual machine as a launch point for compromising other machines on an Azure Virtual Network or even attack networked devices outside of Azure.", + "ImpactStatement": "Restricting RDP access may require alternative methods for remote administration such as VPN or Azure Bastion.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Network security groups`. 1. Under `Settings`, click `Inbound security rules`. 1. Check the box next to any inbound security rule matching: - Port: `3389` or range including 3389 - Protocol: `TCP` or `Any` - Source: `0.0.0.0/0`, `Internet`, or `Any` - Action: `Allow` 1. Click `Delete`. 1. Click `Yes`. **Remediate from Azure CLI** For each network security group rule requiring remediation, run the following command to delete a rule: ``` az network nsg rule delete --resource-group --nsg-name --name ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Network security groups`. 1. Under `Settings`, click `Inbound security rules`. 1. Ensure that no inbound security rule exists that matches the following: - Port: `3389` or range including 3389 - Protocol: `TCP` or `Any` - Source: `0.0.0.0/0`, `Internet`, or `Any` - Action: `Allow` 1. Repeat steps 1-3 for each network security group. To audit from Azure Resource Graph: 1. Go to `Resource Graph Explorer`. 1. Click `New query`. 1. Paste the following into the query window: ``` resources | where type =~ microsoft.network/networksecuritygroups | project id, name, securityRule = properties.securityRules | mv-expand securityRule | extend access = securityRule.properties.access, direction = securityRule.properties.direction, protocol = securityRule.properties.protocol, destinationPort = case(isempty(securityRule.properties.destinationPortRange), securityRule.properties.destinationPortRanges, securityRule.properties.destinationPortRange), sourceAddress = case(isempty(securityRule.properties.sourceAddressPrefix), securityRule.properties.sourceAddressPrefixes, securityRule.properties.sourceAddressPrefix) | where access =~ Allow and direction =~ Inbound and protocol in~ (tcp, ) | mv-expand destinationPort | mv-expand sourceAddress | extend destinationPortMin = toint(split(destinationPort, -)[0]), destinationPortMax = toint(split(destinationPort, -)[-1]) | where (destinationPortMin <= 3389 and destinationPortMax >= 3389) or destinationPort == | where sourceAddress in~ (*, 0.0.0.0, internet, any) or sourceAddress endswith /0 ``` 1. Click `Run query`. 1. Ensure that no results are returned. **Audit from Azure CLI** List network security groups with non-default security rules: ``` az network nsg list --query [*].[name,securityRules] ``` Ensure that no network security group has an inbound security rule that matches the following: ``` access : Allow destinationPortRange : 3389, *, or direction : Inbound protocol : TCP or * sourceAddressPrefix : 0.0.0.0/0, Internet, or * ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [22730e10-96f6-4aac-ad84-9383d35b5917](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F22730e10-96f6-4aac-ad84-9383d35b5917) **- Name:** 'Management ports should be closed on your virtual machines'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security/azure-security-network-security-best-practices#disable-rdpssh-access-to-azure-virtual-machines:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-network-security#ns-1-establish-network-segmentation-boundaries:Express Route: https://docs.microsoft.com/en-us/azure/expressroute/:Site-to-Site VPN: https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-site-to-site-resource-manager-portal:Point-to-Site VPN: https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-point-to-site-resource-manager-portal", + "DefaultValue": "By default, RDP access from internet is not `enabled`." + } + ] + }, + { + "Id": "7.2", + "Description": "Ensure that SSH Access from the Internet is Evaluated and Restricted", + "Checks": [ + "network_ssh_internet_access_restricted" + ], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that SSH Access from the Internet is Evaluated and Restricted", + "RationaleStatement": "The potential security problem with using SSH over the Internet is that attackers can use various brute force techniques to gain access to Azure Virtual Machines. Once the attackers gain access, they can use a virtual machine as a launch point for compromising other machines on the Azure Virtual Network or even attack networked devices outside of Azure.", + "ImpactStatement": "Restricting SSH access may require alternative methods for remote administration such as VPN or Azure Bastion.", + "RemediationProcedure": "Where SSH is not explicitly required and narrowly configured for resources attached to the Network Security Group, Internet-level access to your Azure resources should be restricted or eliminated. For internal access to relevant resources, configure an encrypted network tunnel such as: [ExpressRoute](https://docs.microsoft.com/en-us/azure/expressroute/) [Site-to-site VPN](https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-site-to-site-resource-manager-portal) [Point-to-site VPN](https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-point-to-site-resource-manager-portal)", + "AuditProcedure": "**Audit from Azure Portal** 1. Open the `Networking` blade for the specific Virtual machine in Azure portal 2. Verify that the `INBOUND PORT RULES` **does not** have a rule for SSH such as - port = `22`, - protocol = `TCP` OR `ANY`, - Source = `Any` OR `Internet` **Audit from Azure CLI** List Network security groups with corresponding non-default Security rules: ``` az network nsg list --query [*].[name,securityRules] ``` Ensure that none of the NSGs have security rule as below ``` access : Allow destinationPortRange : 22 or * or [port range containing 22] direction : Inbound protocol : TCP or * sourceAddressPrefix : * or 0.0.0.0 or /0 or /0 or internet or any ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [22730e10-96f6-4aac-ad84-9383d35b5917](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F22730e10-96f6-4aac-ad84-9383d35b5917) **- Name:** 'Management ports should be closed on your virtual machines'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security/azure-security-network-security-best-practices#disable-rdpssh-access-to-azure-virtual-machines:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-network-security#ns-1-establish-network-segmentation-boundaries:Express Route: https://docs.microsoft.com/en-us/azure/expressroute/:Site-to-Site VPN: https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-site-to-site-resource-manager-portal:Point-to-Site VPN: https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-point-to-site-resource-manager-portal", + "DefaultValue": "By default, SSH access from internet is not `enabled`." + } + ] + }, + { + "Id": "7.3", + "Description": "Ensure that UDP Port Access from the Internet is Evaluated and Restricted", + "Checks": [ + "network_udp_internet_access_restricted" + ], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that UDP Port Access from the Internet is Evaluated and Restricted", + "RationaleStatement": "The potential security problem with broadly exposing UDP services over the Internet is that attackers can use DDoS amplification techniques to reflect spoofed UDP traffic from Azure Virtual Machines. The most common types of these attacks use exposed DNS, NTP, SSDP, SNMP, CLDAP and other UDP-based services as amplification sources for disrupting services of other machines on the Azure Virtual Network or even attack networked devices outside of Azure.", + "ImpactStatement": "Restricting UDP access may impact services that legitimately require UDP traffic.", + "RemediationProcedure": "Where UDP is not explicitly required and narrowly configured for resources attached to the Network Security Group, Internet-level access to your Azure resources should be restricted or eliminated. For internal access to relevant resources, configure an encrypted network tunnel such as: [ExpressRoute](https://docs.microsoft.com/en-us/azure/expressroute/) [Site-to-site VPN](https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-site-to-site-resource-manager-portal) [Point-to-site VPN](https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-point-to-site-resource-manager-portal)", + "AuditProcedure": "**Audit from Azure Portal** 1. Open the `Networking` blade for the specific Virtual machine in Azure portal 2. Verify that the `INBOUND PORT RULES` **does not** have a rule for UDP such as - protocol = `UDP`, - Source = `Any` OR `Internet` **Audit from Azure CLI** List Network security groups with corresponding non-default Security rules: ``` az network nsg list --query [*].[name,securityRules] ``` Ensure that none of the NSGs have security rule as below ``` access : Allow destinationPortRange : * or [port range containing 53, 123, 161, 389, 1900, or other vulnerable UDP-based services] direction : Inbound protocol : UDP sourceAddressPrefix : * or 0.0.0.0 or /0 or /0 or internet or any ```", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security/fundamentals/network-best-practices#secure-your-critical-azure-service-resources-to-only-your-virtual-networks:https://docs.microsoft.com/en-us/azure/security/fundamentals/ddos-best-practices:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-network-security#ns-1-establish-network-segmentation-boundaries:ExpressRoute: https://docs.microsoft.com/en-us/azure/expressroute/:Site-to-site VPN: https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-site-to-site-resource-manager-portal:Point-to-site VPN: https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-point-to-site-resource-manager-portal", + "DefaultValue": "By default, UDP access from internet is not `enabled`." + } + ] + }, + { + "Id": "7.4", + "Description": "Ensure that HTTP(S) Access from the Internet is Evaluated and Restricted", + "Checks": [ + "network_http_internet_access_restricted" + ], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that HTTP(S) Access from the Internet is Evaluated and Restricted", + "RationaleStatement": "The potential security problem with using HTTP(S) over the Internet is that attackers can use various brute force techniques to gain access to Azure resources. Once the attackers gain access, they can use the resource as a launch point for compromising other resources within the Azure tenant.", + "ImpactStatement": "Restricting HTTP(S) access may require proper configuration of web application firewalls and load balancers.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Virtual machines`. 2. For each VM, open the `Networking` blade. 3. Click on `Inbound port rules`. 4. Delete the rule with: * Port = 80/443 OR \\[port range containing 80/443\\] * Protocol = TCP OR Any * Source = Any (\\*) OR IP Addresses(0.0.0.0/0) OR Service Tag(Internet) * Action = Allow **Remediate from Azure CLI** 1. Run below command to list network security groups: ``` az network nsg list --subscription --output table ``` 2. For each network security group, run below command to list the rules associated with the specified port: ``` az network nsg rule list --resource-group --nsg-name --query [?destinationPortRange=='80 or 443'] ``` 3. Run the below command to delete the rule with: * Port = 80/443 OR \\[port range containing 80/443\\] * Protocol = TCP OR * * Source = Any (\\*) OR IP Addresses(0.0.0.0/0) OR Service Tag(Internet) * Action = Allow ``` az network nsg rule delete --resource-group --nsg-name --name ```", + "AuditProcedure": "**Audit from Azure Portal** 1. For each VM, open the Networking blade 2. Verify that the INBOUND PORT RULES does not have a rule for HTTP(S) such as - port = `80`/ `443`, - protocol = `TCP`, - Source = `Any` OR `Internet` **Audit from Azure CLI** List Network security groups with corresponding non-default Security rules: ``` az network nsg list --query [*].[name,securityRules] ``` Ensure that none of the NSGs have security rule as below ``` access : Allow destinationPortRange : 80/443 or * or [port range containing 80/443] direction : Inbound protocol : TCP sourceAddressPrefix : * or 0.0.0.0 or /0 or /0 or internet or any ```", + "AdditionalInformation": "", + "References": "Express Route: https://docs.microsoft.com/en-us/azure/expressroute/:Site-to-Site VPN: https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-site-to-site-resource-manager-portal:Point-to-Site VPN: https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-point-to-site-resource-manager-portal:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-network-security#ns-1-establish-network-segmentation-boundaries", + "DefaultValue": "" + } + ] + }, + { + "Id": "7.5", + "Description": "Ensure that Network Security Group Flow Log Retention Days is Set to Greater than or equal to 90", + "Checks": [ + "network_flow_log_more_than_90_days" + ], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure that Network Security Group Flow Log Retention Days is Set to Greater than or equal to 90", + "RationaleStatement": "Virtual network flow logs provide critical visibility into traffic patterns. Logs can be used to check for anomalies and give insight into suspected breaches.", + "ImpactStatement": "* Virtual network flow logs are charged per gigabyte of network flow logs collected and come with a free tier of 5 GB/month per subscription. * If traffic analytics is enabled with virtual network flow logs, traffic analytics pricing applies at per gigabyte processing rates. * The storage of logs is charged separately, and the cost will depend on the amount of logs and the retention period.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down menu, select `Flow log type`. 1. From the `Value` drop-down menu, check `Virtual network` only. 1. Click `Apply`. 1. Click the name of a virtual network flow log. 1. Under `Storage Account`, set `Retention days` to `0`, `90`, or a number greater than 90. If `Retention days` is set to `0`, the logs are retained indefinitely with no retention policy. 1. Repeat steps 7 and 8 for each virtual network flow log requiring remediation. **Remediate from Azure CLI** Run the following command update the retention policy for a flow log in a network watcher, setting `retention` to `0`, `90`, or a number greater than 90: ``` az network watcher flow-log update --location --name --retention ``` Repeat for each virtual network flow log requiring remediation.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down menu, select `Flow log type`. 1. From the `Value` drop-down menu, check `Virtual network` only. 1. Click `Apply`. 1. Click the name of a virtual network flow log. 1. Under `Storage Account`, ensure that `Retention days` is set to `0`, `90`, or a number greater than 90. If `Retention days` is set to `0`, the logs are retained indefinitely with no retention policy. 1. Repeat steps 7 and 8 for each virtual network flow log. **Audit from Azure CLI** Run the following command to list network watchers: ``` az network watcher list ``` Run the following command to list the name and retention policy of flow logs in a network watcher: ``` az network watcher flow-log list --location --query [*].[name,retentionPolicy] ``` For each flow log, ensure that `days` is set to `0`, `90`, or a number greater than 90. If `days` is set to `0`, the logs are retained indefinitely with no retention policy. Repeat for each network watcher.", + "AdditionalInformation": "As network security group flow logs are on the retirement path, Azure recommends migrating to virtual network flow logs.", + "References": "https://learn.microsoft.com/en-us/azure/network-watcher/vnet-flow-logs-portal:https://learn.microsoft.com/en-us/cli/azure/network/watcher/flow-log", + "DefaultValue": "When a virtual network flow log is created using the Azure CLI, retention days is set to 0 by default. When creating via the Azure Portal, retention days must be specified by the creator." + } + ] + }, + { + "Id": "7.6", + "Description": "Ensure that Network Watcher is 'Enabled' for Azure Regions That are in Use", + "Checks": [ + "network_watcher_enabled" + ], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure that Network Watcher is 'Enabled' for Azure Regions That are in Use", + "RationaleStatement": "Network diagnostic and visualization tools available with Network Watcher help users understand, diagnose, and gain insights to the network in Azure.", + "ImpactStatement": "There are additional costs per transaction to run and store network data. For high-volume networks these charges will add up quickly.", + "RemediationProcedure": "Opting out of Network Watcher automatic enablement is a permanent change. Once you opt-out you cannot opt-in without contacting support. To manually enable Network Watcher in each region where you want to use Network Watcher capabilities, follow the steps below. **Remediate from Azure Portal** 1. Use the Search bar to search for and click on the `Network Watcher` service. 1. Click `Create`. 1. Select a `Region` from the drop-down menu. 1. Click `Add`. **Remediate from Azure CLI** ``` az network watcher configure --locations --enabled true --resource-group ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Use the Search bar to search for and click on the `Network Watcher` service. 1. From the Overview menu item, review each Network Watcher listed, and ensure that a network watcher is listed for each region in use by the subscription. **Audit from Azure CLI** ``` az network watcher list --query [].{Location:location,State:provisioningState} -o table ``` This will list all network watchers and their provisioning state. Ensure `provisioningState` is `Succeeded` for each network watcher. ``` az account list-locations --query [?metadata.regionType=='Physical'].{Name:name,DisplayName:regionalDisplayName} -o table ``` This will list all physical regions that exist in the subscription. Compare this list to the previous one to ensure that for each region in use, a network watcher exists with `provisioningState` set to `Succeeded`. **Audit from PowerShell** Get a list of Network Watchers ``` Get-AzNetworkWatcher ``` Make sure each watcher is set with the `ProvisioningState` setting set to `Succeeded` and all `Locations` that are in use by the subscription are using a watcher. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [b6e2945c-0b7b-40f5-9233-7a5323b5cdc6](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb6e2945c-0b7b-40f5-9233-7a5323b5cdc6) **- Name:** 'Network Watcher should be enabled'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-monitoring-overview:https://learn.microsoft.com/en-us/cli/azure/network/watcher?view=azure-cli-latest:https://learn.microsoft.com/en-us/cli/azure/network/watcher?view=azure-cli-latest#az-network-watcher-configure:https://learn.microsoft.com/en-us/azure/network-watcher/network-watcher-create:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-4-enable-network-logging-for-security-investigation:https://azure.microsoft.com/en-ca/pricing/details/network-watcher/", + "DefaultValue": "Network Watcher is automatically enabled. When you create or update a virtual network in your subscription, Network Watcher will be enabled automatically in your Virtual Network's region. There is no impact to your resources or associated charge for automatically enabling Network Watcher." + } + ] + }, + { + "Id": "7.7", + "Description": "Ensure that Public IP Addresses are Evaluated on a Periodic Basis", + "Checks": [ + "network_public_ip_shodan" + ], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that Public IP Addresses are Evaluated on a Periodic Basis", + "RationaleStatement": "Public IP Addresses allocated to the tenant should be periodically reviewed for necessity. Public IP Addresses that are not intentionally assigned and controlled present a publicly facing vector for threat actors and significant risk to the tenant.", + "ImpactStatement": "Regular reviews require administrative effort.", + "RemediationProcedure": "Remediation will vary significantly depending on your organization's security requirements for the resources attached to each individual Public IP address.", + "AuditProcedure": "**Audit from Azure Portal** 1. Open the `All Resources` blade 2. Click on `Add Filter` 3. In the Add Filter window, select the following: Filter: `Type` Operator: `Equals` Value: `Public IP address` 4. Click the `Apply` button 5. For each Public IP address in the list, use Overview (or Properties) to review the `Associated to:` field and determine if the associated resource is still relevant to your tenant environment. If the associated resource is relevant, ensure that additional controls exist to mitigate risk (e.g. Firewalls, VPNs, Traffic Filtering, Virtual Gateway Appliances, Web Application Firewalls, etc.) on all subsequently attached resources. **Audit from Azure CLI** List all Public IP addresses: ``` az network public-ip list ``` For each Public IP address in the output, review the `name` property and determine if the associated resource is still relevant to your tenant environment. If the associated resource is relevant, ensure that additional controls exist to mitigate risk (e.g. Firewalls, VPNs, Traffic Filtering, Virtual Gateway Appliances, Web Application Firewalls, etc.) on all subsequently attached resources.", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/cli/azure/network/public-ip?view=azure-cli-latest:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-network-security", + "DefaultValue": "During Virtual Machine and Application creation, a setting may create and attach a public IP." + } + ] + }, + { + "Id": "7.8", + "Description": "Ensure that Virtual Network Flow Log Retention Days is Set to Greater than or Equal to 90", + "Checks": [ + "network_flow_log_more_than_90_days" + ], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure that Virtual Network Flow Log Retention Days is Set to Greater than or Equal to 90", + "RationaleStatement": "Virtual network flow logs provide critical visibility into traffic patterns. Logs can be used to check for anomalies and give insight into suspected breaches.", + "ImpactStatement": "* Virtual network flow logs are charged per gigabyte of network flow logs collected and come with a free tier of 5 GB/month per subscription. * If traffic analytics is enabled with virtual network flow logs, traffic analytics pricing applies at per gigabyte processing rates. * The storage of logs is charged separately, and the cost will depend on the amount of logs and the retention period.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down menu, select `Flow log type`. 1. From the `Value` drop-down menu, check `Virtual network` only. 1. Click `Apply`. 1. Click the name of a virtual network flow log. 1. Under `Storage Account`, set `Retention days` to `0`, `90`, or a number greater than 90. If `Retention days` is set to `0`, the logs are retained indefinitely with no retention policy. 1. Repeat steps 7 and 8 for each virtual network flow log requiring remediation. **Remediate from Azure CLI** Run the following command update the retention policy for a flow log in a network watcher, setting `retention` to `0`, `90`, or a number greater than 90: ``` az network watcher flow-log update --location --name --retention ``` Repeat for each virtual network flow log requiring remediation.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down menu, select `Flow log type`. 1. From the `Value` drop-down menu, check `Virtual network` only. 1. Click `Apply`. 1. Click the name of a virtual network flow log. 1. Under `Storage Account`, ensure that `Retention days` is set to `0`, `90`, or a number greater than 90. If `Retention days` is set to `0`, the logs are retained indefinitely with no retention policy. 1. Repeat steps 7 and 8 for each virtual network flow log. **Audit from Azure CLI** Run the following command to list network watchers: ``` az network watcher list ``` Run the following command to list the name and retention policy of flow logs in a network watcher: ``` az network watcher flow-log list --location --query [*].[name,retentionPolicy] ``` For each flow log, ensure that `days` is set to `0`, `90`, or a number greater than 90. If `days` is set to `0`, the logs are retained indefinitely with no retention policy. Repeat for each network watcher.", + "AdditionalInformation": "As network security group flow logs are on the retirement path, Azure recommends migrating to virtual network flow logs.", + "References": "https://learn.microsoft.com/en-us/azure/network-watcher/vnet-flow-logs-portal:https://learn.microsoft.com/en-us/cli/azure/network/watcher/flow-log", + "DefaultValue": "When a virtual network flow log is created using the Azure CLI, retention days is set to 0 by default. When creating via the Azure Portal, retention days must be specified by the creator." + } + ] + }, + { + "Id": "7.9", + "Description": "Ensure 'Authentication type' is Set to 'Azure Active Directory' only for Azure VPN Gateway Point-to-Site Configuration", + "Checks": [], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure 'Authentication type' is Set to 'Azure Active Directory' only for Azure VPN Gateway Point-to-Site Configuration", + "RationaleStatement": "Microsoft Entra ID authentication provides strong security and centralized identity management, and reduces risks associated with static credentials and certificate management.", + "ImpactStatement": "Azure VPN Gateways incur hourly charges, with additional costs for point-to-site connections and data transfer. Pricing varies by SKU and usage. Refer to https://azure.microsoft.com/en-us/pricing/details/vpn-gateway/ for details.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Virtual network gateways`. 2. Under `VPN gateway`, click `VPN gateways`. 3. Click the name of a VPN gateway. 4. Under `Settings`, click `Point-to-site configuration`. 5. Ensure `Authentication type` click to expand the drop-down menu. 6. Check the box next to `Azure Active Directory`, and uncheck the boxes next to `Azure certificate` and `RADIUS authentication`. 7. Provide a `Tenant`, `Audience`, and `Issuer` for the Azure Active Directory configuration. 8. Click `Save`. 9. Repeat steps 1-8 for each VPN gateway requiring remediation.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Virtual network gateways`. 2. Under `VPN gateway`, click `VPN gateways`. 3. Click the name of a VPN gateway. 4. Under `Settings`, click `Point-to-site configuration`. 5. Ensure `Authentication type` is set to `Azure Active Directory` only. 6. Repeat steps 1-5 for each VPN gateway. **Audit from Azure Policy** - **Policy ID:** 21a6bc25-125e-4d13-b82d-2e19b7208ab7 - **Name:** 'VPN gateways should use only Azure Active Directory (Azure AD) authentication for point-to-site users'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-about-vpngateways:https://learn.microsoft.com/en-us/azure/vpn-gateway/point-to-site-entra-gateway:https://learn.microsoft.com/en-us/azure/vpn-gateway/openvpn-azure-ad-tenant", + "DefaultValue": "'Authentication type' is selected during creation of point-to-site configuration." + } + ] + }, + { + "Id": "7.10", + "Description": "Ensure Azure Web Application Firewall (WAF) is Enabled on Azure Application Gateway", + "Checks": [], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Azure Web Application Firewall (WAF) is Enabled on Azure Application Gateway", + "RationaleStatement": "Using Azure Web Application Firewall with Azure Application Gateway reduces exposure to external threats by mitigating attacks on public facing applications.", + "ImpactStatement": "The WAF V2 tier for Azure Application Gateways costs more than the Basic and Standard V2 tiers. Pricing includes a fixed hourly charge plus a charge per capacity-unit hour. Refer to https://azure.microsoft.com/en-gb/pricing/details/application-gateway/ for details.", + "RemediationProcedure": "**Note:** Basic tier application gateways cannot be upgraded to the WAF V2 tier. Create a new WAF V2 tier application gateway to replace a Basic tier application gateway. **Remediate from Azure Portal** To remediate a Standard V2 tier application gateway: 1. Go to `Application gateways`. 2. Click `Add filter`. 3. From the `Filter` drop-down menu, select `SKU size`. 4. Check the box next to `Standard_v2` only. 5. Click `Apply`. 6. Click the name of an application gateway. 7. Under `Settings`, click `Web application firewall`. 8. Under `Configure`, next to `Tier`, click `WAF V2`. 9. Select an existing or create a new WAF policy. 10. Click `Save`. 11. Repeat steps 1-10 for each Standard V2 tier application gateway requiring remediation.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Application gateways`. 2. Click the name of an application gateway. 3. In the `Overview`, under `Essentials`, ensure `Tier` is set to `WAF V2`. 4. Repeat steps 1-3 for each application gateway. **Audit from Azure CLI** Run the following command to list application gateways: ``` az network application-gateway list ``` For each application gateway, run the following command to get the firewall policy id: ``` az network application-gateway show --resource-group --name --query firewallPolicy.id ``` Ensure a firewall policy id is returned. **Audit from Azure Policy** - **Policy ID:** 564feb30-bf6a-4854-b4bb-0d2d2d1e6c66 - **Name:** 'Web Application Firewall (WAF) should be enabled for Application Gateway'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/application-gateway/features:https://learn.microsoft.com/en-us/cli/azure/network/application-gateway:https://azure.microsoft.com/en-us/pricing/details/application-gateway", + "DefaultValue": "Azure Web Application Firewall is enabled by default for the WAF V2 tier of Azure Application Gateway. It is not available in the Basic tier. Application gateways deployed using the Standard V2 tier can be upgraded to the WAF V2 tier to enable Azure Web Application Firewall." + } + ] + }, + { + "Id": "7.11", + "Description": "Ensure Subnets Are Associated with Network Security Groups", + "Checks": [ + "network_subnet_nsg_associated" + ], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure Subnets Are Associated with Network Security Groups", + "RationaleStatement": "Unprotected subnets can expose resources to unauthorized access.", + "ImpactStatement": "Minor administrative effort is required to ensure subnets are associated with network security groups. There is no cost to create or use network security groups.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Virtual networks`. 2. Click the name of a virtual network. 3. Under `Settings`, click `Subnets`. 4. Click the name of a subnet. 5. Under `Security`, next to `Network security group`, click `None` to display the drop-down menu. 6. Select a network security group. 7. Click `Save`. 8. Repeat steps 1-7 for each virtual network and subnet requiring remediation. **Remediate from Azure CLI** For each subnet requiring remediation, run the following command to associate it with a network security group: ``` az network vnet subnet update --resource-group --vnet-name --name --network-security-group ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Virtual networks`. 2. Click the name of a virtual network. 3. Under `Settings`, click `Subnets`. 4. Click the name of a subnet. 5. Under `Security`, ensure `Network security group` is not set to `None`. 6. Repeat steps 1-5 for each virtual network and subnet. **Audit from Azure CLI** Run the following command to list virtual networks: ``` az network vnet list ``` For each virtual network, run the following command to list subnets: ``` az network vnet show --resource-group --name --query subnets ``` For each subnet, run the following command to get the network security group id: ``` az network vnet subnet show --resource-group --vnet-name --name --query networkSecurityGroup.id ``` Ensure a network security group id is returned. **Audit from Azure Policy** - **Policy ID:** e71308d3-144b-4262-b144-efdc3cc90517 - **Name:** 'Subnets should be associated with a Network Security Group'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview:https://learn.microsoft.com/en-us/cli/azure/network/vnet", + "DefaultValue": "By default, a subnet is not associated with a network security group." + } + ] + }, + { + "Id": "7.12", + "Description": "Ensure the SSL Policy's 'Min protocol version' is Set to 'TLSv1_2' or Higher on Azure Application Gateway", + "Checks": [], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure the SSL Policy's 'Min protocol version' is Set to 'TLSv1_2' or Higher on Azure Application Gateway", + "RationaleStatement": "TLS 1.0 and 1.1 are outdated and vulnerable to security risks. Since TLS 1.2 and TLS 1.3 provide enhanced security and improved performance, it is highly recommended to use TLS 1.2 or higher whenever possible.", + "ImpactStatement": "Using the latest TLS version may affect compatibility with clients and backend services.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Application gateways`. 2. Click the name of an application gateway. 3. Under `Settings`, click `Listeners`. 4. Under `SSL Policy`, next to the Selected SSL Policy name, click `change`. 5. Select an appropriate SSL policy with a `Min protocol version` of `TLSv1_2` or higher. 6. Click `Save`. 7. Repeat steps 1-6 for each application gateway requiring remediation. **Remediate from Azure CLI** Run the following command to list available SSL policy options: ``` az network application-gateway ssl-policy list-options ``` Run the following command to list available predefined SSL policies: ``` az network application-gateway ssl-policy predefined list ``` For each application gateway requiring remediation, run the following command to set a predefined SSL policy: ``` az network application-gateway ssl-policy set --resource-group --gateway-name --name --policy-type Predefined ``` Alternatively, run the following command to set a custom SSL policy: ``` az network application-gateway ssl-policy set --resource-group --gateway-name --policy-type Custom --min-protocol-version --cipher-suites ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Application gateways`. 2. Click the name of an application gateway. 3. Under `Settings`, click `Listeners`. 4. Under `SSL Policy`, ensure `Min protocol version` is set to `TLSv1_2` or higher. 5. Repeat steps 1-4 for each application gateway. **Audit from Azure CLI** Run the following command to list application gateways: ``` az network application-gateway list ``` For each application gateway, run the following command to get the SSL policy: ``` az network application-gateway ssl-policy show --resource-group --gateway-name ``` For each SSL policy, run the following command to get the minProtocolVersion: ``` az network application-gateway ssl-policy predefined show --name --query minProtocolVersion ``` Ensure `TLSv1_2` or higher is returned.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/application-gateway/application-gateway-ssl-policy-overview:https://learn.microsoft.com/en-us/cli/azure/network/application-gateway", + "DefaultValue": "Min protocol version is set to TLSv1_2 by default." + } + ] + }, + { + "Id": "7.13", + "Description": "Ensure 'HTTP2' is Set to 'Enabled' on Azure Application Gateway", + "Checks": [], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure 'HTTP2' is Set to 'Enabled' on Azure Application Gateway", + "RationaleStatement": "Enabling HTTP/2 supports use of modern encrypted connections.", + "ImpactStatement": "Clients and backend services that do not support HTTP/2 will fall back to HTTP/1.1.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Application gateways`. 2. Click the name of an application gateway. 3. Under `Settings`, click `Configuration`. 4. Under `HTTP2`, click `Enabled`. 5. Click `Save`. 6. Repeat steps 1-5 for each application gateway requiring remediation. **Remediate from Azure CLI** For each application gateway requiring remediation, run the following command to enable HTTP2: ``` az network application-gateway update --resource-group --name --http2 Enabled ``` **Remediate from PowerShell** Run the following command to get the application gateway in a resource group with a given name: ``` $gateway = Get-AzApplicationGateway -ResourceGroupName -Name ``` Run the following command to enable HTTP2: ``` $gateway.EnableHttp2 = $true ``` Run the following command to apply the update: ``` Set-AzApplicationGateway -ApplicationGateway $gateway ``` Repeat for each application gateway requiring remediation.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Application gateways`. 2. Click the name of an application gateway. 3. Under `Settings`, click `Configuration`. 4. Ensure `HTTP2` is set to `Enabled`. 5. Repeat steps 1-4 for each application gateway. **Audit from Azure CLI** Run the following command to list application gateways: ``` az network application-gateway list ``` For each application gateway, run the following command to get the HTTP2 setting: ``` az network application-gateway show --resource-group --name --query enableHttp2 ``` Ensure `true` is returned. **Audit from PowerShell** Run the following command to list application gateways: ``` Get-AzApplicationGateway ``` Run the following command to get the application gateway in a resource group with a given name: ``` $gateway = Get-AzApplicationGateway -ResourceGroupName -Name ``` Run the following command to get the HTTP2 setting: ``` $gateway.EnableHttp2 ``` Ensure that `True` is returned. Repeat for each application gateway.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/application-gateway/features#websocket-and-http2-traffic:https://learn.microsoft.com/en-us/cli/azure/network/application-gateway:https://learn.microsoft.com/en-us/powershell/module/az.network/get-azapplicationgateway:https://learn.microsoft.com/en-us/powershell/module/az.network/set-azapplicationgateway", + "DefaultValue": "HTTP2 is enabled by default." + } + ] + }, + { + "Id": "7.14", + "Description": "Ensure Request Body Inspection is Enabled in Azure Web Application Firewall policy on Azure Application Gateway", + "Checks": [], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Request Body Inspection is Enabled in Azure Web Application Firewall policy on Azure Application Gateway", + "RationaleStatement": "Enabling request body inspection strengthens security by allowing the Web Application Firewall to detect common attacks, such as SQL injection and cross-site scripting.", + "ImpactStatement": "Minor performance impact on the Web Application Firewall. Additional effort may be required to monitor findings.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Application gateways`. 2. Click the name of an application gateway. 3. Under `Settings`, click `Web application firewall`. 4. Under `Associated web application firewall policy`, click the policy name. 5. Under `Settings`, click `Policy settings`. 6. Check the box next to `Enforce request body inspection`. 7. Click `Save`. 8. Repeat steps 1-7 for each application gateway and firewall policy requiring remediation. **Remediate from Azure CLI** For each firewall policy requiring remediation, run the following command to enable request body inspection: ``` az network application-gateway waf-policy update --ids --policy-settings request-body-check=true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Application gateways`. 2. Click the name of an application gateway. 3. Under `Settings`, click `Web application firewall`. 4. Under `Associated web application firewall policy`, click the policy name. 5. Under `Settings`, click `Policy settings`. 6. Ensure the box next to `Enforce request body inspection` is checked. 7. Repeat steps 1-6 for each application gateway. **Audit from Azure CLI** Run the following command to list application gateways: ``` az network application-gateway list ``` For each application gateway, run the following command to get the firewall policy id: ``` az network application-gateway show --resource-group --name --query firewallPolicy.id ``` For each firewall policy, run the following command to get the request body inspection setting: ``` az network application-gateway waf-policy show --ids --query policySettings.requestBodyCheck ``` Ensure `true` is returned. **Audit from Azure Policy** - **Policy ID:** ca85ef9a-741d-461d-8b7a-18c2da82c666 - **Name:** 'Azure Web Application Firewall on Azure Application Gateway should have request body inspection enabled'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-gb/azure/web-application-firewall/ag/application-gateway-waf-request-size-limits#request-body-inspection:https://learn.microsoft.com/en-us/cli/azure/network/application-gateway:https://learn.microsoft.com/en-us/cli/azure/network/application-gateway/waf-policy", + "DefaultValue": "Request body inspection is enabled by default on Azure Application Gateways with Web Application Firewall." + } + ] + }, + { + "Id": "7.15", + "Description": "Ensure Bot Protection is Enabled in Azure Web Application Firewall Policy on Azure Application Gateway", + "Checks": [], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Bot Protection is Enabled in Azure Web Application Firewall Policy on Azure Application Gateway", + "RationaleStatement": "Internet traffic from bots can scrape, scan, and search for application vulnerabilities. Enabling bot protection stops requests from known malicious IP addresses and enhances the overall security of your application by reducing exposure to automated attacks.", + "ImpactStatement": "May require monitoring to identify false positives.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Application gateways`. 2. Click the name of an application gateway. 3. Under `Settings`, click `Web application firewall`. 4. Under `Associated web application firewall policy`, click the policy name. 5. Under `Settings`, click `Managed rules`. 6. Click `Assign`. 7. Under `Bot Management ruleset`, click to display the drop-down menu. 8. Select a `Microsoft_BotManagerRuleSet`. 9. Click `Save`. 10. Click `X` to close the panel. 11. Repeat steps 1-10 for each application gateway and firewall policy requiring remediation. **Remediate from Azure CLI** For each firewall policy requiring remediation, run the following command to enable bot protection: ``` az network application-gateway waf-policy managed-rule rule-set add --resource-group --policy-name --type Microsoft_BotManagerRuleSet --version <0.1|1.0|1.1> ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Application gateways`. 2. Click the name of an application gateway. 3. Under `Settings`, click `Web application firewall`. 4. Under `Associated web application firewall policy`, click the policy name. 5. Under `Settings`, click `Managed rules`. 6. Ensure a `Rule Id` containing `Microsoft_BotManagerRuleSet` is listed. 7. Click the `>` to expand the row. 8. Ensure the `Status` for `Malicious Bots` is set to `Enabled`. 9. Repeat steps 1-8 for each application gateway. **Audit from Azure CLI** Run the following command to list application gateways: ``` az network application-gateway list ``` For each application gateway, run the following command to get the firewall policy id: ``` az network application-gateway show --resource-group --name --query firewallPolicy.id ``` For each firewall policy, run the following command to get the managed rule sets: ``` az network application-gateway waf-policy show --ids --query managedRules.managedRuleSets ``` Ensure a managed rule set with `ruleSetType` of `Microsoft_BotManagerRuleSet` is returned, and that no `ruleGroupOverrides` for `ruleGroupName` `KnownBadBots` with `state` `Disabled` are returned. **Audit from Azure Policy** - **Policy ID:** ebea0d86-7fbd-42e3-8a46-27e7568c2525 - **Name:** 'Bot Protection should be enabled for Azure Application Gateway WAF'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/web-application-firewall/ag/bot-protection-overview:https://learn.microsoft.com/en-us/azure/web-application-firewall/ag/bot-protection:https://learn.microsoft.com/en-us/cli/azure/network/application-gateway:https://learn.microsoft.com/en-us/cli/azure/network/application-gateway/waf-policy:https://learn.microsoft.com/en-us/cli/azure/network/application-gateway/waf-policy/managed-rule/rule-set", + "DefaultValue": "Bot protection is disabled by default on Azure Application Gateways with Web Application Firewall." + } + ] + }, + { + "Id": "7.16", + "Description": "Ensure Azure Network Security Perimeter is Used to Secure Azure Platform-as-a-service Resources", + "Checks": [], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure Azure Network Security Perimeter is Used to Secure Azure Platform-as-a-service Resources", + "RationaleStatement": "Network security perimeter denies public access to PaaS resources, reducing exposure and mitigating data exfiltration risks.", + "ImpactStatement": "Implementation requires administrative effort to configure and maintain network security perimeter profiles and resource assignments. Azure does not list any additional charges for using network security perimeters.", + "RemediationProcedure": "**Remediate from Azure Portal** Create and associate PaaS resources with a new network security perimeter: 1. Go to `Network Security Perimeters`. 2. Click `+ Create`. 3. Select a `Subscription` and `Resource group`, provide a `Name`, select a `Region`, and provide a `Profile name`. 4. Click `Next`. 5. Click `+ Add`. 6. Check the box next to a PaaS resource to associate it with the network security perimeter. 7. Click `Select`. 8. Click `Next`. 9. Configure appropriate `Inbound access rules` for your organization. 10. Click `Next`. 11. Configure appropriate `Outbound access rules` for your organization. 12. Click `Review + create`. 13. Click `Create`. **Remediate from Azure CLI** Use `az network perimeter profile list` or `az network perimeter profile create` to list existing or create a new network security perimeter profile. For each PaaS resource requiring association with a network security perimeter, run the following command: ``` az network perimeter association create --resource-group --perimeter-name --association-name --private-link-resource \"{id:}\" --profile \"{}\" ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Resource groups`. 2. Click the name of a resource group. 3. Take note of PaaS resources. 4. Go to `Network Security Perimeters`. 5. Click the name of a network security perimeter. 6. Under `Settings`, click `Associated resources`. 7. Take note of the associated resources. 8. Repeat steps 1-7 and ensure each PaaS resource is associated with a network security perimeter. **Audit from Azure CLI** Run the following command to list resource groups: ``` az group list ``` For each resource group, run the following command to list resources: ``` az resource list --resource-group ``` Take note of PaaS resources. For each resource group, run the following command to list network security perimeters: ``` az network perimeter list --resource-group ``` For each network security perimeter, run the following command to list resources: ``` az network perimeter association list --resource-group --perimeter-name ``` Ensure each PaaS resource is associated with a network security perimeter.", + "AdditionalInformation": "The current list of resources that can be associated with a network security perimeter are as follows: Azure Monitor, Azure AI Search, Cosmos DB, Event Hubs, Key Vault, SQL DB, Storage, Azure OpenAI Service. While network security perimeter is generally available, Cosmos DB, SQL DB, and Azure OpenAI Service are in public preview.", + "References": "https://learn.microsoft.com/en-us/azure/private-link/network-security-perimeter-concepts:https://learn.microsoft.com/en-us/azure/private-link/create-network-security-perimeter-portal:https://learn.microsoft.com/en-us/cli/azure/group:https://learn.microsoft.com/en-us/cli/azure/resource:https://learn.microsoft.com/en-us/cli/azure/network/perimeter", + "DefaultValue": "PaaS resources are not associated with a network security perimeter by default." + } + ] + }, + { + "Id": "8.1.1.1", + "Description": "Ensure Microsoft Defender CSPM is Set to 'On'", + "Checks": [ + "defender_ensure_defender_cspm_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Microsoft Defender CSPM is Set to 'On'", + "RationaleStatement": "Microsoft Defender CSPM provides detailed visibility into the security state of assets and workloads and offers hardening guidance to help improve security posture.", + "ImpactStatement": "Enabling Microsoft Defender CSPM incurs hourly charges for each billable compute, database, and storage resource. This can lead to significant costs in larger environments. Careful planning and cost analysis are recommended before enabling the service. Refer to https://azure.microsoft.com/en-us/pricing/details/defender-for-cloud/#pricing for pricing information.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, click `Environment settings`. 3. Click the name of a subscription. 4. Select the `Defender plans` blade. 5. Under `Cloud Security Posture Management (CSPM)`, in the row for `Defender CSPM`, set the toggle switch for `Status` to `On`. 6. Click `Save`. **Remediate from Azure CLI** Run the following command to enable Defender CSPM: ``` az security pricing create --name CloudPosture --tier Standard --extensions name=ApiPosture isEnabled=true ``` **Remediate from PowerShell** Run the following command to enable Defender CSPM: ``` Set-AzSecurityPricing -Name CloudPosture -PricingTier Standard -Extension '[{\"name\":\"ApiPosture\",\"isEnabled\":\"True\"}]' ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, click `Environment settings`. 3. Click the name of a subscription. 4. Select the `Defender plans` blade. 5. Under `Cloud Security Posture Management (CSPM)`, in the row for `Defender CSPM`, ensure `Status` is set to `On`. **Audit from Azure CLI** Run the following command to get the CloudPosture plan pricing tier: ``` az security pricing show --name CloudPosture --query pricingTier ``` Ensure `Standard` is returned. **Audit from PowerShell** Run the following command to get the CloudPosture plan pricing tier: ``` Get-AzSecurityPricing -Name CloudPosture | Select-Object PricingTier ``` Ensure `Standard` is returned. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [1f90fc71-a595-4066-8974-d4d0802e8ef0](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F1f90fc71-a595-4066-8974-d4d0802e8ef0) **- Name:** 'Microsoft Defender CSPM should be enabled'", + "AdditionalInformation": "", + "References": "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/tutorial-enable-cspm-plan:https://azure.microsoft.com/en-us/pricing/details/defender-for-cloud/#pricing:https://learn.microsoft.com/en-us/cli/azure/security/pricing:https://learn.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/powershell/module/az.security/set-azsecuritypricing", + "DefaultValue": "Defender CSPM is disabled by default." + } + ] + }, + { + "Id": "8.1.2.1", + "Description": "Ensure Microsoft Defender for APIs is Set to 'On'", + "Checks": [ + "defender_ensure_defender_for_app_services_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Microsoft Defender for APIs is Set to 'On'", + "RationaleStatement": "Enabling Microsoft Defender for App Service allows for greater defense-in-depth, with threat detection provided by the Microsoft Security Response Center (MSRC).", + "ImpactStatement": "Turning on Microsoft Defender for App Service incurs an additional cost per resource.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud` 2. Under `Management`, select `Environment Settings` 3. Click on the subscription name 4. Select `Defender plans` 5. Set `App Service` Status to `On` 6. Select `Save` **Remediate from Azure CLI** Run the following command: ``` az security pricing create -n Appservices --tier 'standard' ``` **Remediate from PowerShell** Run the following command: ``` Set-AzSecurityPricing -Name AppServices -PricingTier Standard ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud` 2. Under `Management`, select `Environment Settings` 3. Click on the subscription name 4. Select `Defender plans` 5. Ensure Status is `On` for `App Service` **Audit from Azure CLI** Run the following command: ``` az security pricing show -n AppServices ``` Ensure `-PricingTier` is set to `Standard` **Audit from PowerShell** Run the following command: ``` Get-AzSecurityPricing -Name 'AppServices' |Select-Object Name,PricingTier ``` Ensure the `-PricingTier` is set to `Standard` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [2913021d-f2fd-4f3d-b958-22354e2bdbcb](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F2913021d-f2fd-4f3d-b958-22354e2bdbcb) **- Name:** 'Azure Defender for App Service should be enabled'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/list:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/update:https://docs.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "By default, Microsoft Defender plan is off." + } + ] + }, + { + "Id": "8.1.3.1", + "Description": "Ensure that Defender for Servers is Set to 'On'", + "Checks": [ + "defender_ensure_defender_for_server_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure that Defender for Servers is Set to 'On'", + "RationaleStatement": "Enabling Defender for Servers allows for greater defense-in-depth, with threat detection provided by the Microsoft Security Response Center (MSRC).", + "ImpactStatement": "Enabling Defender for Servers in Microsoft Defender for Cloud incurs an additional cost per resource. Refer to https://azure.microsoft.com/en-us/pricing/details/defender-for-cloud/ and https://azure.microsoft.com/en-us/pricing/calculator/ to estimate potential costs. - Plan 1: Subscription only - Plan 2: Subscription and workspace", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment settings`. 1. Click on a subscription name. 1. Click `Defender plans` in the left pane. 1. Under `Cloud Workload Protection (CWP)`, locate `Servers` in the Plan column, set Status to `On`. 1. Select `Save`. 1. Repeat steps 1-6 for each subscription requiring remediation. **Remediate from Azure CLI** Run the following command: ``` az security pricing create -n VirtualMachines --tier 'standard' ``` **Remediate from PowerShell** Run the following command: ``` Set-AzSecurityPricing -Name 'VirtualMachines' -PricingTier 'Standard' ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment settings`. 1. Click on a subscription name. 1. Select `Defender plans` in the left pane. 1. Under `Cloud Workload Protection (CWP)`, locate `Servers` in the Plan column, ensure Status is set to `On`. 1. Repeat steps 1-5 for each subscription. **Audit from Azure CLI** Run the following command: ``` az security pricing show -n VirtualMachines --query pricingTier ``` If the tenant is licensed and enabled, the output will indicate `Standard`. **Audit from PowerShell** Run the following command: ``` Get-AzSecurityPricing -Name 'VirtualMachines' |Select-Object Name,PricingTier ``` If the tenant is licensed and enabled, the `-PricingTier` parameter will indicate `Standard`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [4da35fc9-c9e7-4960-aec9-797fe7d9051d](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4da35fc9-c9e7-4960-aec9-797fe7d9051d) **- Name:** 'Azure Defender for servers should be enabled'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-servers-overview:https://learn.microsoft.com/en-us/azure/defender-for-cloud/plan-defender-for-servers:https://learn.microsoft.com/en-us/rest/api/defenderforcloud/pricings/list:https://learn.microsoft.com/en-us/rest/api/defenderforcloud/pricings/update:https://learn.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/powershell/module/az.security/set-azsecuritypricing:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-endpoint-security#es-1-use-endpoint-detection-and-response-edr", + "DefaultValue": "By default, the Defender for Servers plan is disabled." + } + ] + }, + { + "Id": "8.1.3.2", + "Description": "Ensure that 'Vulnerability assessment for machines' Component Status is set to 'On'", + "Checks": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that 'Vulnerability assessment for machines' Component Status is set to 'On'", + "RationaleStatement": "Vulnerability assessment for machines scans for various security-related configurations and events such as system updates, OS vulnerabilities, and endpoint protection, then produces alerts on threat and vulnerability findings.", + "ImpactStatement": "Microsoft Defender for Servers plan 2 licensing is required, and configuration of Azure Arc introduces complexity beyond this recommendation.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Defender for Cloud` 1. Under `Management`, select `Environment Settings` 1. Select a subscription 1. Click on `Settings & Monitoring` 1. Set the `Status` of `Vulnerability assessment for machines` to `On` 1. Click `Continue` Repeat the above for any additional subscriptions.", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Defender for Cloud` 1. Under `Management`, select `Environment Settings` 1. Select a subscription 1. Click on `Settings & monitoring` 1. Ensure that `Vulnerability assessment for machines` is set to `On` Repeat the above for any additional subscriptions.", + "AdditionalInformation": "While this feature is generally available as of publication, it is not yet available for Azure Government tenants.", + "References": "https://docs.microsoft.com/en-us/azure/defender-for-cloud/enable-data-collection?tabs=autoprovision-va:https://msdn.microsoft.com/en-us/library/mt704062.aspx:https://msdn.microsoft.com/en-us/library/mt704063.aspx:https://docs.microsoft.com/en-us/rest/api/securitycenter/autoprovisioningsettings/list:https://docs.microsoft.com/en-us/rest/api/securitycenter/autoprovisioningsettings/create:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-posture-vulnerability-management#pv-5-perform-vulnerability-assessments", + "DefaultValue": "By default, `Automatic provisioning of monitoring agent` is set to `Off`." + } + ] + }, + { + "Id": "8.1.3.3", + "Description": "Ensure that 'Endpoint protection' Component Status is set to 'On'", + "Checks": [ + "defender_assessments_vm_endpoint_protection_installed" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure that 'Endpoint protection' Component Status is set to 'On'", + "RationaleStatement": "Microsoft Defender for Endpoint integration brings comprehensive Endpoint Detection and Response (EDR) capabilities within Microsoft Defender for Cloud. This integration helps to spot abnormalities, as well as detect and respond to advanced attacks on endpoints monitored by Microsoft Defender for Cloud. MDE works only with Standard Tier subscriptions.", + "ImpactStatement": "Endpoint protection requires licensing and is included in these plans: - Defender for Servers plan 1 - Defender for Servers plan 2", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Go to `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment Settings`. 1. Click on the subscription name. 1. Click `Settings & monitoring`. 1. Set the `Status` for `Endpoint protection` to `On`. 1. Click `Continue`. **Remediate from Azure CLI** Use the below command to enable Standard pricing tier for Storage Accounts ``` az account get-access-token --query {subscription:subscription,accessToken:accessToken} --out tsv | xargs -L1 bash -c 'curl -X PUT -H Authorization: Bearer $1 -H Content-Type: application/json https://management.azure.com/subscriptions//providers/Microsoft.Security/settings/WDATP?api-version=2021-06-01 -d@input.json' ``` Where input.json contains the Request body json data as mentioned below. ``` { id: /subscriptions//providers/Microsoft.Security/settings/WDATP, kind: DataExportSettings, type: Microsoft.Security/settings, properties: { enabled: true } } ```", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment Settings`. 1. Click on the subscription name. 1. Click `Settings & monitoring`. 1. Ensure the `Status` for `Endpoint protection` is set to `On`. **Audit from Azure CLI** Ensure the output of the below command is `True` ``` az account get-access-token --query {subscription:subscription,accessToken:accessToken} --out tsv | xargs -L1 bash -c 'curl -X GET -H Authorization: Bearer $1 -H Content-Type: application/json https://management.azure.com/subscriptions//providers/Microsoft.Security/settings?api-version=2021-06-01' | jq '.|.value[] | select(.name==WDATP)'|jq '.properties.enabled' ``` **Audit from PowerShell** Run the following commands to login and audit this check ``` Connect-AzAccount Set-AzContext -Subscription Get-AzSecuritySetting | Select-Object name,enabled |where-object {$_.name -eq WDATP} ``` **PowerShell Output - Non-Compliant** ``` Name Enabled ---- ------- WDATP False ``` **PowerShell Output - Compliant** ``` Name Enabled ---- ------- WDATP True ```", + "AdditionalInformation": "**IMPORTANT:** When enabling integration between DfE & DfC it needs to be taken into account that this will have some side effects that may be undesirable. 1. For server 2019 & above if defender is installed (default for these server SKUs) this will trigger a deployment of the new unified agent and link to any of the extended configuration in the Defender portal. 1. If the new unified agent is required for server SKUs of Win 2016 or Linux and lower there is additional integration that needs to be switched on and agents need to be aligned. NOTE: Microsoft Defender for Endpoint (MDE) was formerly known as Windows Defender Advanced Threat Protection (WDATP). There are a number of places (e.g. Azure CLI) where the WDATP acronym is still used within Azure.", + "References": "https://docs.microsoft.com/en-in/azure/defender-for-cloud/integration-defender-for-endpoint?tabs=windows:https://docs.microsoft.com/en-us/rest/api/securitycenter/settings/list:https://docs.microsoft.com/en-us/rest/api/securitycenter/settings/update:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-endpoint-security#es-1-use-endpoint-detection-and-response-edr:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-endpoint-security#es-2-use-modern-anti-malware-software", + "DefaultValue": "By default, Endpoint protection is `off`." + } + ] + }, + { + "Id": "8.1.3.4", + "Description": "Ensure that 'Agentless scanning for machines' Component Status is Set to 'On'", + "Checks": [], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that 'Agentless scanning for machines' Component Status is Set to 'On'", + "RationaleStatement": "The Microsoft Defender for Cloud agentless machine scanner provides threat detection, vulnerability detection, and discovery of sensitive information.", + "ImpactStatement": "Agentless scanning for machines requires licensing and is included in these plans: - Defender CSPM - Defender for Servers plan 2", + "RemediationProcedure": "**Audit from Azure Portal** 1. From the Azure Portal `Home` page, select `Microsoft Defender for Cloud` 1. Under `Management` select `Environment Settings` 1. Select a subscription 1. Under `Settings` > `Defender Plans`, click `Settings & monitoring` 1. Under the Component column, locate the row for `Agentless scanning for machines` 1. Select `On` 1. Click `Continue` in the top left Repeat the above for any additional subscriptions.", + "AuditProcedure": "**Audit from Azure Portal** 1. From the Azure Portal `Home` page, select `Microsoft Defender for Cloud` 1. Under `Management` select `Environment Settings` 1. Select a subscription 1. Under `Settings` > `Defender Plans`, click `Settings & monitoring` 1. Under the Component column, locate the row for `Agentless scanning for machines` 1. Ensure that `On` is selected Repeat the above for any additional subscriptions.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/concept-agentless-data-collection:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-incident-response#ir-2-preparation---setup-incident-notification:https://learn.microsoft.com/en-us/azure/defender-for-cloud/enable-agentless-scanning-vms", + "DefaultValue": "By default, Agentless scanning for machines is `off`." + } + ] + }, + { + "Id": "8.1.3.5", + "Description": "Ensure that 'File Integrity Monitoring' Component Status is Set to 'On'", + "Checks": [], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that 'File Integrity Monitoring' Component Status is Set to 'On'", + "RationaleStatement": "FIM provides a detection mechanism for compromised files. When FIM is enabled, critical system files are monitored for changes that might indicate a threat actor is attempting to modify system files for lateral compromise within a host operating system.", + "ImpactStatement": "File Integrity Monitoring requires licensing and is included in these plans: - Defender for Servers plan 2", + "RemediationProcedure": "**Audit from Azure Portal** 1. From the Azure Portal `Home` page, select `Microsoft Defender for Cloud` 1. Under `Management` select `Environment Settings` 1. Select a subscription 1. Under `Settings` > `Defender Plans`, click `Settings & monitoring` 1. Under the Component column, locate the row for `File Integrity Monitoring` 1. Select `On` 1. Click `Continue` in the top left Repeat the above for any additional subscriptions.", + "AuditProcedure": "**Audit from Azure Portal** 1. From the Azure Portal `Home` page, select `Microsoft Defender for Cloud` 1. Under `Management` select `Environment Settings` 1. Select a subscription 1. Under `Settings` > `Defender Plans`, click `Settings & monitoring` 1. Under the Component column, locate the row for `File Integrity Monitoring` 1. Ensure that `On` is selected Repeat the above for any additional subscriptions.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/file-integrity-monitoring-overview:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-incident-response#ir-2-preparation---setup-incident-notification:https://learn.microsoft.com/en-us/azure/defender-for-cloud/file-integrity-monitoring-enable-defender-endpoint", + "DefaultValue": "By default, File Integrity Monitoring is `Off`." + } + ] + }, + { + "Id": "8.1.4.1", + "Description": "Ensure That Microsoft Defender for Containers Is Set To 'On'", + "Checks": [ + "defender_ensure_defender_for_containers_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure That Microsoft Defender for Containers Is Set To 'On'", + "RationaleStatement": "Enabling Microsoft Defender for Containers enhances defense-in-depth by providing advanced threat detection, vulnerability assessment, and security monitoring for containerized environments, leveraging insights from the Microsoft Security Response Center (MSRC).", + "ImpactStatement": "Microsoft Defender for Containers incurs a charge per vCore. Refer to https://azure.microsoft.com/en-us/pricing/details/defender-for-cloud/ and https://azure.microsoft.com/en-us/pricing/calculator/ to estimate potential costs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 1. Under `Management`, click `Environment settings`. 1. Click the name of a subscription. 1. Under `Settings`, click `Defender plans`. 1. Under `Cloud Workload Protection (CWP)`, in the row for `Containers`, click `On` in the `Status` column. 1. If `Monitoring coverage` displays `Partial`, click `Settings` under `Partial`. 1. Set the status of each of the components to `On`. 1. Click `Continue`. 1. Click `Save`. 1. Repeat steps 1-9 for each subscription. **Remediate from Azure CLI** **Note:** Microsoft Defender for Container Registries ('ContainerRegistry') is deprecated and has been replaced by Microsoft Defender for Containers ('Containers'). Run the below command to enable the Microsoft Defender for Containers plan and its components: ``` az security pricing create -n 'Containers' --tier 'standard' --extensions name=ContainerRegistriesVulnerabilityAssessments isEnabled=True --extensions name=AgentlessDiscoveryForKubernetes isEnabled=True --extensions name=AgentlessVmScanning isEnabled=True --extensions name=ContainerSensor isEnabled=True ``` **Remediate from PowerShell** **Note:** Microsoft Defender for Container Registries ('ContainerRegistry') is deprecated and has been replaced by Microsoft Defender for Containers ('Containers'). Run the below command to enable the Microsoft Defender for Containers plan and its components: ``` Set-AzSecurityPricing -Name 'Containers' -PricingTier 'Standard' -Extension '[{name:ContainerRegistriesVulnerabilityAssessments,isEnabled:True},{name:AgentlessDiscoveryForKubernetes,isEnabled:True},{name:AgentlessVmScanning,isEnabled:True},{name:ContainerSensor,isEnabled:True}]' ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 1. Under `Management`, click `Environment settings`. 1. Click the name of a subscription. 1. Under `Settings`, click `Defender plans`. 1. Under `Cloud Workload Protection (CWP)`, in the row for `Containers`, ensure that the `Status` is set to `On` and `Monitoring coverage` displays `Full`. 1. Repeat steps 1-5 for each subscription. **Audit from Azure CLI** For Microsoft Defender for Container Registries (deprecated), run the following command: ``` az security pricing show --name ContainerRegistry --query pricingTier ``` Ensure that the command returns `Standard`. For Microsoft Defender for Containers, run the following command: ``` az security pricing show --name Containers --query [pricingTier,extensions[*].[name,isEnabled]] ``` Ensure that the command returns `Standard`, and that each of the extensions (ContainerRegistriesVulnerabilityAssessments, AgentlessDiscoveryForKubernetes, AgentlessVmScanning, ContainerSensor) returns `True`. Repeat for each subscription. **Audit from PowerShell** For Microsoft Defender for Container Registries (deprecated), run the following command: ``` Get-AzSecurityPricing -Name 'ContainerRegistry' | Select-Object Name,PricingTier ``` Ensure the command returns `PricingTier` `Standard`. For Microsoft Defender for Containers, run the following command: ``` Get-AzSecurityPricing -Name 'Containers' ``` Ensure that `PricingTier` is set to `Standard`, and that each of the extensions (ContainerRegistriesVulnerabilityAssessments, AgentlessDiscoveryForKubernetes, AgentlessVmScanning, ContainerSensor) has `isEnabled` set to `True`. Repeat for each subscription. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [1c988dd6-ade4-430f-a608-2a3e5b0a6d38](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F1c988dd6-ade4-430f-a608-2a3e5b0a6d38) **- Name:** 'Microsoft Defender for Containers should be enabled'", + "AdditionalInformation": "The Azure Policy 'Microsoft Defender for Containers should be enabled' checks only that the `pricingTier` for `Containers` is set to `Standard`. It does not check the status of the plan's components.", + "References": "https://learn.microsoft.com/en-us/cli/azure/security/pricing:https://learn.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/powershell/module/az.security/set-azsecuritypricing:https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-containers-introduction:https://learn.microsoft.com/en-us/azure/defender-for-cloud/tutorial-enable-containers-azure:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "The Microsoft Defender for Containers plan is disabled by default." + } + ] + }, + { + "Id": "8.1.5.1", + "Description": "Ensure That Microsoft Defender for Storage Is Set To 'On'", + "Checks": [ + "defender_ensure_defender_for_storage_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure That Microsoft Defender for Storage Is Set To 'On'", + "RationaleStatement": "Enabling Microsoft Defender for Storage allows for greater defense-in-depth, with threat detection provided by the Microsoft Security Response Center (MSRC).", + "ImpactStatement": "Turning on Microsoft Defender for Storage incurs an additional cost per resource.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Set `Status` to `On` for `Storage`. 6. Select `Save`. **Remediate from Azure CLI** Ensure the output of the below command is Standard ``` az security pricing create -n StorageAccounts --tier 'standard' ``` **Remediate from PowerShell** ``` Set-AzSecurityPricing -Name 'StorageAccounts' -PricingTier 'Standard' ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Ensure `Status` is set to `On` for `Storage`. **Audit from Azure CLI** Ensure the output of the below command is Standard ``` az security pricing show -n StorageAccounts ``` **Audit from PowerShell** ``` Get-AzSecurityPricing -Name 'StorageAccounts' | Select-Object Name,PricingTier ``` Ensure output for `Name PricingTier` is `StorageAccounts Standard` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [640d2586-54d2-465f-877f-9ffc1d2109f4](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F640d2586-54d2-465f-877f-9ffc1d2109f4) **- Name:** 'Microsoft Defender for Storage should be enabled'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/list:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/update:https://docs.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "By default, Microsoft Defender plan is off." + } + ] + }, + { + "Id": "8.1.5.2", + "Description": "Ensure Advanced Threat Protection Alerts for Storage Accounts Are Monitored", + "Checks": [], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure Advanced Threat Protection Alerts for Storage Accounts Are Monitored", + "RationaleStatement": "Enabling Microsoft Defender for Storage without a monitoring process limits its value. Continuous monitoring and alert triage ensure that detected threats are acted upon quickly, reducing risk exposure.", + "ImpactStatement": "Requires integration effort with SIEM or alerting tools and a defined incident response process. The amount of data logged and, thus, the cost incurred can vary significantly depending on the tenant size and the applications in your tenant that interact with the Microsoft Graph APIs. See pricing: Log Analytics (https://learn.microsoft.com/en-us/azure/azure-monitor/logs/cost-logs#pricing-model), Azure Storage (https://azure.microsoft.com/en-us/pricing/details/storage/blobs/), Event Hubs (https://azure.microsoft.com/en-us/pricing/details/event-hubs/).", + "RemediationProcedure": "Connect Microsoft Defender for Cloud to a SIEM such as Microsoft Sentinel or another log analytics solution. **Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, click `Environment Settings`. 3. Expand the Tenant Root Group(s) to reveal subscriptions. For each subscription listed: 1. Click the subscription name to open the Defender Plans settings 2. In the settings on the left, click `Continuous Export` 3. Select either `Event Hub`, `Log Analytics Workspace`, or both depending on your environment. 4. Set `Export enabled` to `On` 5. Under `Exported data types`, ensure that at least `Security Alerts (Medium and High)` is checked. 6. Under `Export target`, set the target Event Hub or Log Analytics Workspace which is tied to a SIEM that is configured to monitor and alert for security alerts. Ensure security alerts are included in the security operations workflow and incident response plan.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, click `Environment Settings`. 3. Expand the Tenant Root Group(s) to reveal subscriptions. For each subscription listed: 1. Click the subscription name to open the Defender Plans settings 2. In the settings on the left, click `Continuous Export` Ensure that `Export enabled` is set to `On` and delivering at least `Security Alerts (Medium and High)` to an Event Hub or Log Analytics Workspace which is tied to a SIEM that is configured to monitor and alert for security alerts.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/azure/defender-for-cloud/alerts-overview:https://learn.microsoft.com/azure/sentinel/connect-defender-for-cloud:https://learn.microsoft.com/en-us/azure/defender-for-cloud/continuous-export", + "DefaultValue": "By default, continuous export is off." + } + ] + }, + { + "Id": "8.1.6.1", + "Description": "Ensure That Microsoft Defender for App Services Is Set To 'On'", + "Checks": [ + "defender_ensure_defender_for_app_services_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure That Microsoft Defender for App Services Is Set To 'On'", + "RationaleStatement": "Enabling Microsoft Defender for App Service allows for greater defense-in-depth, with threat detection provided by the Microsoft Security Response Center (MSRC).", + "ImpactStatement": "Turning on Microsoft Defender for App Service incurs an additional cost per resource.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud` 2. Under `Management`, select `Environment Settings` 3. Click on the subscription name 4. Select `Defender plans` 5. Set `App Service` Status to `On` 6. Select `Save` **Remediate from Azure CLI** Run the following command: ``` az security pricing create -n Appservices --tier 'standard' ``` **Remediate from PowerShell** Run the following command: ``` Set-AzSecurityPricing -Name AppServices -PricingTier Standard ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud` 2. Under `Management`, select `Environment Settings` 3. Click on the subscription name 4. Select `Defender plans` 5. Ensure Status is `On` for `App Service` **Audit from Azure CLI** Run the following command: ``` az security pricing show -n AppServices ``` Ensure `-PricingTier` is set to `Standard` **Audit from PowerShell** Run the following command: ``` Get-AzSecurityPricing -Name 'AppServices' |Select-Object Name,PricingTier ``` Ensure the `-PricingTier` is set to `Standard` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [2913021d-f2fd-4f3d-b958-22354e2bdbcb](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F2913021d-f2fd-4f3d-b958-22354e2bdbcb) **- Name:** 'Azure Defender for App Service should be enabled'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/list:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/update:https://docs.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "By default, Microsoft Defender plan is off." + } + ] + }, + { + "Id": "8.1.7.1", + "Description": "Ensure That Microsoft Defender for Azure Cosmos DB Is Set To 'On'", + "Checks": [ + "defender_ensure_defender_for_cosmosdb_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure That Microsoft Defender for Azure Cosmos DB Is Set To 'On'", + "RationaleStatement": "In scanning Azure Cosmos DB requests within a subscription, requests are compared to a heuristic list of potential security threats. These threats could be a result of a security breach within your services, thus scanning for them could prevent a potential security threat from being introduced.", + "ImpactStatement": "Enabling Microsoft Defender for Azure Cosmos DB requires enabling Microsoft Defender for your subscription. Both will incur additional charges.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. On the `Database` row click on `Select types >`. 6. Set the toggle switch next to `Azure Cosmos DB` to `On`. 7. Click `Continue`. 8. Click `Save`. **Remediate from Azure CLI** Run the following command: ``` az security pricing create -n 'CosmosDbs' --tier 'standard' ``` **Remediate from PowerShell** Use the below command to enable Standard pricing tier for Azure Cosmos DB ``` Set-AzSecurityPricing -Name 'CosmosDbs' -PricingTier 'Standard ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. On the `Database` row click on `Select types >`. 6. Ensure the toggle switch next to `Azure Cosmos DB` is set to `On`. **Audit from Azure CLI** Ensure the output of the below command is Standard ``` az security pricing show -n CosmosDbs --query pricingTier ``` **Audit from PowerShell** ``` Get-AzSecurityPricing -Name 'CosmosDbs' | Select-Object Name,PricingTier ``` Ensure output of `-PricingTier` is `Standard` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [adbe85b5-83e6-4350-ab58-bf3a4f736e5e](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fadbe85b5-83e6-4350-ab58-bf3a4f736e5e) **- Name:** 'Microsoft Defender for Azure Cosmos DB should be enabled'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/pricing/details/defender-for-cloud/:https://docs.microsoft.com/en-us/azure/defender-for-cloud/enable-enhanced-security:https://docs.microsoft.com/en-us/azure/defender-for-cloud/alerts-overview:https://docs.microsoft.com/en-us/security/benchmark/azure/baselines/cosmos-db-security-baseline:https://docs.microsoft.com/en-us/azure/defender-for-cloud/quickstart-enable-database-protections:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "By default, Microsoft Defender for Azure Cosmos DB is not enabled." + } + ] + }, + { + "Id": "8.1.7.2", + "Description": "Ensure That Microsoft Defender for Open-Source Relational Databases Is Set To 'On'", + "Checks": [ + "defender_ensure_defender_for_os_relational_databases_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure That Microsoft Defender for Open-Source Relational Databases Is Set To 'On'", + "RationaleStatement": "Enabling Microsoft Defender for Open-source relational databases allows for greater defense-in-depth, with threat detection provided by the Microsoft Security Response Center (MSRC).", + "ImpactStatement": "Turning on Microsoft Defender for Open-source relational databases incurs an additional cost per resource.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Click `Select types >` in the row for `Databases`. 6. Set the toggle switch next to `Open-source relational databases` to `On`. 7. Select `Continue`. 8. Select `Save`. **Remediate from Azure CLI** Run the following command: ``` az security pricing create -n 'OpenSourceRelationalDatabases' --tier 'standard' ``` **Remediate from PowerShell** Use the below command to enable Standard pricing tier for Open-source relational databases ``` set-azsecuritypricing -name OpenSourceRelationalDatabases -pricingtier Standard ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment Settings`. 1. Click on the subscription name. 1. Select the `Defender plans` blade. 1. Click `Select types >` in the row for `Databases`. 1. Ensure the toggle switch next to `Open-source relational databases` is set to `On`. **Audit from Azure CLI** Run the following command: ``` az security pricing show -n OpenSourceRelationalDatabases --query pricingTier ``` **Audit from PowerShell** ``` Get-AzSecurityPricing | Where-Object {$_.Name -eq 'OpenSourceRelationalDatabases'} | Select-Object Name, PricingTier ``` Ensure output for `Name PricingTier` is `OpenSourceRelationalDatabases Standard` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [0a9fbe0d-c5c4-4da8-87d8-f4fd77338835](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F0a9fbe0d-c5c4-4da8-87d8-f4fd77338835) **- Name:** 'Azure Defender for open-source relational databases should be enabled'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/update:https://docs.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-2-monitor-anomalies-and-threats-targeting-sensitive-data:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "By default, Microsoft Defender plan is off." + } + ] + }, + { + "Id": "8.1.7.3", + "Description": "Ensure That Microsoft Defender for (Managed Instance) Azure SQL Databases Is Set To 'On'", + "Checks": [ + "defender_ensure_defender_for_azure_sql_databases_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure That Microsoft Defender for (Managed Instance) Azure SQL Databases Is Set To 'On'", + "RationaleStatement": "Enabling Microsoft Defender for Azure SQL Databases allows for greater defense-in-depth, includes functionality for discovering and classifying sensitive data, surfacing and mitigating potential database vulnerabilities, and detecting anomalous activities that could indicate a threat to your database.", + "ImpactStatement": "Turning on Microsoft Defender for Azure SQL Databases incurs an additional cost per resource.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Click `Select types >` in the row for `Databases`. 6. Set the toggle switch next to `Azure SQL Databases` to `On`. 7. Select `Continue`. 8. Select `Save`. **Remediate from Azure CLI** Run the following command: ``` az security pricing create -n SqlServers --tier 'standard' ``` **Remediate from PowerShell** Run the following command: ``` Set-AzSecurityPricing -Name 'SqlServers' -PricingTier 'Standard' ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Click `Select types >` in the row for `Databases`. 6. Ensure the toggle switch next to `Azure SQL Databases` is set to `On`. **Audit from Azure CLI** Run the following command: ``` az security pricing show -n SqlServers ``` Ensure `-PricingTier` is set to `Standard` **Audit from PowerShell** Run the following command: ``` Get-AzSecurityPricing -Name 'SqlServers' | Select-Object Name,PricingTier ``` Ensure the `-PricingTier` is set to `Standard` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [7fe3b40f-802b-4cdd-8bd4-fd799c948cc2](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F7fe3b40f-802b-4cdd-8bd4-fd799c948cc2) **- Name:** 'Azure Defender for Azure SQL Database servers should be enabled' - **Policy ID:** [abfb7388-5bf4-4ad7-ba99-2cd2f41cebb9](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fabfb7388-5bf4-4ad7-ba99-2cd2f41cebb9) **- Name:** 'Azure Defender for SQL should be enabled for unprotected SQL Managed Instances'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/list:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/update:https://docs.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-2-monitor-anomalies-and-threats-targeting-sensitive-data:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "By default, Microsoft Defender plan is off." + } + ] + }, + { + "Id": "8.1.7.4", + "Description": "Ensure That Microsoft Defender for SQL Servers on Machines Is Set To 'On'", + "Checks": [ + "defender_ensure_defender_for_sql_servers_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure That Microsoft Defender for SQL Servers on Machines Is Set To 'On'", + "RationaleStatement": "Enabling Microsoft Defender for SQL servers on machines allows for greater defense-in-depth, functionality for discovering and classifying sensitive data, surfacing and mitigating potential database vulnerabilities, and detecting anomalous activities that could indicate a threat to your database.", + "ImpactStatement": "Turning on Microsoft Defender for SQL servers on machines incurs an additional cost per resource.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Click `Select types >` in the row for `Databases`. 6. Set the toggle switch next to `SQL servers on machines` to `On`. 7. Select `Continue`. 8. Select `Save`. **Remediate from Azure CLI** Run the following command: ``` az security pricing create -n SqlServerVirtualMachines --tier 'standard' ``` **Remediate from PowerShell** Run the following command: ``` Set-AzSecurityPricing -Name 'SqlServerVirtualMachines' -PricingTier 'Standard' ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Click `Select types >` in the row for `Databases`. 6. Ensure the toggle switch next to `SQL servers on machines` is set to `On`. **Audit from Azure CLI** Ensure Defender for SQL is licensed with the following command: ``` az security pricing show -n SqlServerVirtualMachines ``` Ensure the 'PricingTier' is set to 'Standard' **Audit from PowerShell** Run the following command: ``` Get-AzSecurityPricing -Name 'SqlServerVirtualMachines' | Select-Object Name,PricingTier ``` Ensure the 'PricingTier' is set to 'Standard' **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [6581d072-105e-4418-827f-bd446d56421b](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F6581d072-105e-4418-827f-bd446d56421b) **- Name:** 'Azure Defender for SQL servers on machines should be enabled' - **Policy ID:** [abfb4388-5bf4-4ad7-ba82-2cd2f41ceae9](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fabfb4388-5bf4-4ad7-ba82-2cd2f41ceae9) **- Name:** 'Azure Defender for SQL should be enabled for unprotected Azure SQL servers'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security-center/defender-for-sql-usage:https://docs.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/update:https://docs.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-2-monitor-anomalies-and-threats-targeting-sensitive-data:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "By default, Microsoft Defender plan is off." + } + ] + }, + { + "Id": "8.1.8.1", + "Description": "Ensure That Microsoft Defender for Key Vault Is Set To 'On'", + "Checks": [ + "defender_ensure_defender_for_keyvault_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure That Microsoft Defender for Key Vault Is Set To 'On'", + "RationaleStatement": "Enabling Microsoft Defender for Key Vault allows for greater defense-in-depth, with threat detection provided by the Microsoft Security Response Center (MSRC).", + "ImpactStatement": "Turning on Microsoft Defender for Key Vault incurs an additional cost per resource.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Select `On` under `Status` for `Key Vault`. 6. Select `Save`. **Remediate from Azure CLI** Enable Standard pricing tier for Key Vault: ``` az security pricing create -n 'KeyVaults' --tier 'Standard' ``` **Remediate from PowerShell** Enable Standard pricing tier for Key Vault: ``` Set-AzSecurityPricing -Name 'KeyVaults' -PricingTier 'Standard' ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Ensure `Status` is set to `On` for `Key Vault`. **Audit from Azure CLI** Ensure the output of the below command is Standard ``` az security pricing show -n 'KeyVaults' --query 'pricingTier' ``` **Audit from PowerShell** ``` Get-AzSecurityPricing -Name 'KeyVaults' | Select-Object Name,PricingTier ``` Ensure output for `PricingTier` is `Standard` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [0e6763cc-5078-4e64-889d-ff4d9a839047](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F0e6763cc-5078-4e64-889d-ff4d9a839047) **- Name:** 'Azure Defender for Key Vault should be enabled'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/list:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/update:https://docs.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "By default, Microsoft Defender plan is off." + } + ] + }, + { + "Id": "8.1.9.1", + "Description": "Ensure That Microsoft Defender for Resource Manager Is Set To 'On'", + "Checks": [ + "defender_ensure_defender_for_arm_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure That Microsoft Defender for Resource Manager Is Set To 'On'", + "RationaleStatement": "Scanning resource requests lets you be alerted every time there is suspicious activity in order to prevent a security threat from being introduced.", + "ImpactStatement": "Enabling Microsoft Defender for Resource Manager requires enabling Microsoft Defender for your subscription. Both will incur additional charges.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Select `On` under `Status` for `Resource Manager`. 6. Select `Save. **Remediate from Azure CLI** Use the below command to enable Standard pricing tier for Defender for Resource Manager ``` az security pricing create -n 'Arm' --tier 'Standard' ``` **Remediate from PowerShell** Use the below command to enable Standard pricing tier for Defender for Resource Manager ``` Set-AzSecurityPricing -Name 'Arm' -PricingTier 'Standard' ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Ensure `Status` is set to `On` for `Resource Manager`. **Audit from Azure CLI** Ensure the output of the below command is Standard ``` az security pricing show -n 'Arm' --query 'pricingTier' ``` **Audit from PowerShell** ``` Get-AzSecurityPricing -Name 'Arm' | Select-Object Name,PricingTier ``` Ensure the output of `PricingTier` is `Standard` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [c3d20c29-b36d-48fe-808b-99a87530ad99](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc3d20c29-b36d-48fe-808b-99a87530ad99) **- Name:** 'Azure Defender for Resource Manager should be enabled'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/defender-for-cloud/enable-enhanced-security:https://docs.microsoft.com/en-us/azure/defender-for-cloud/defender-for-resource-manager-introduction:https://azure.microsoft.com/en-us/pricing/details/defender-for-cloud/:https://docs.microsoft.com/en-us/azure/defender-for-cloud/alerts-overview:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "By default, Microsoft Defender for Resource Manager is not enabled." + } + ] + }, + { + "Id": "8.1.10", + "Description": "Ensure that Microsoft Defender for Cloud is Configured to Check VM Operating Systems for Updates", + "Checks": [ + "defender_ensure_system_updates_are_applied" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Microsoft Defender for Cloud is Configured to Check VM Operating Systems for Updates", + "RationaleStatement": "Windows and Linux virtual machines should be kept updated to: - Address a specific bug or flaw - Improve an OS or applications general stability - Fix a security vulnerability Microsoft Defender for Cloud retrieves a list of available security and critical updates from Windows Update or Windows Server Update Services (WSUS), depending on which service is configured on a Windows VM. The security center also checks for the latest updates in Linux systems. If a VM is missing a system update, the security center will recommend system updates be applied.", + "ImpactStatement": "Running Microsoft Defender for Cloud incurs additional charges for each resource monitored. Please see attached reference for exact charges per hour.", + "RemediationProcedure": "Follow Microsoft Azure documentation to apply security patches from the security center. Alternatively, you can employ your own patch assessment and management tool to periodically assess, report, and install the required security patches for your OS.", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Defender for Cloud` 1. Then the `Recommendations` blade 1. Ensure that there are no recommendations for `System updates should be installed on your machines (powered by Update Center)` Alternatively, you can employ your own patch assessment and management tool to periodically assess, report and install the required security patches for your OS. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [f85bf3e0-d513-442e-89c3-1784ad63382b](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Ff85bf3e0-d513-442e-89c3-1784ad63382b) **- Name:** 'System updates should be installed on your machines (powered by Update Center)' - **Policy ID:** [bd876905-5b84-4f73-ab2d-2e7a7c4568d9](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fbd876905-5b84-4f73-ab2d-2e7a7c4568d9) **- Name:** 'Machines should be configured to periodically check for missing system updates'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-posture-vulnerability-management#pv-6-rapidly-and-automatically-remediate-vulnerabilities:https://azure.microsoft.com/en-us/pricing/details/defender-for-cloud/:https://docs.microsoft.com/en-us/azure/defender-for-cloud/deploy-vulnerability-assessment-vm", + "DefaultValue": "By default, patches are not automatically deployed." + } + ] + }, + { + "Id": "8.1.11", + "Description": "Ensure that non-deprecated Microsoft Cloud Security Benchmark policies are not set to 'Disabled'", + "Checks": [ + "policy_ensure_asc_enforcement_enabled" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that non-deprecated Microsoft Cloud Security Benchmark policies are not set to 'Disabled'", + "RationaleStatement": "A security policy defines the desired configuration of resources in your environment and helps ensure compliance with company or regulatory security requirements. The MCSB Policy Initiative a set of security recommendations based on best practices and is associated with every subscription by default. When a policy Effect is set to `Audit`, policies in the MCSB ensure that Defender for Cloud evaluates relevant resources for supported recommendations. To ensure that policies within the MCSB are not being missed when the Policy Initiative is evaluated, none of the policies should have an Effect of `Disabled`.", + "ImpactStatement": "Policies within the MCSB default to an effect of `Audit` and will evaluate—but not enforce—policy recommendations. Ensuring these policies are set to `Audit` simply ensures that the evaluation occurs to allow administrators to understand where an improvement may be possible. Administrators will need to determine if the recommendations are relevant and desirable for their environment, then manually take action to resolve the status if desired.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment settings`. 1. Click on the appropriate Management Group or Subscription. 1. Click on `Security policies` in the left column. 1. Click on `Microsoft cloud security benchmark` 1. Click `Add Filter` and select `Effect` 1. Check the `Disabled` box to search for all disabled policies 1. Click `Apply` 1. Click the blue ellipsis `...` to the right of a policy name. 1. Click `Manage effect and parameters`. 1. Under `Policy effect`, select the radio button next to `Audit`. 1. Click `Save`. 1. Click `Refresh`. 1. Repeat steps 10-14 until all disabled policies are updated. 1. Repeat steps 1-15 for each Management Group or Subscription requiring remediation.", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment settings`. 1. Click on the appropriate Management Group or Subscription. 1. Click on `Security policies` in the left column. 1. Click on `Microsoft cloud security benchmark`. 1. Click `Add filter` and select `Effect`. 1. Check the `Disabled` box to search for all disabled policies. 1. Click `Apply`. 1. Ensure that no policies are displayed, signifying that there are no disabled policies. 1. Repeat steps 1-10 for each Management Group or Subscription.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-in/azure/defender-for-cloud/security-policy-concept:https://docs.microsoft.com/en-us/azure/security-center/security-center-policies:https://learn.microsoft.com/en-us/azure/defender-for-cloud/implement-security-recommendations:https://learn.microsoft.com/en-us/rest/api/policy/policy-assignments/get:https://learn.microsoft.com/en-us/rest/api/policy/policy-assignments/create:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-7-define-and-implement-logging-threat-detection-and-incident-response-strategy", + "DefaultValue": "By default, the MCSB policy initiative is assigned on all subscriptions, and **most** policies will have an effect of `Audit`. Some policies will have a default effect of `Disabled`." + } + ] + }, + { + "Id": "8.1.12", + "Description": "Ensure That 'All users with the following roles' is Set to 'Owner'", + "Checks": [ + "defender_ensure_notify_emails_to_owners" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure That 'All users with the following roles' is Set to 'Owner'", + "RationaleStatement": "Enabling security alert emails to subscription owners ensures that they receive security alert emails from Microsoft. This ensures that they are aware of any potential security issues and can mitigate the risk in a timely fashion.", + "ImpactStatement": "Owners will receive email notifications for security alerts.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Defender for Cloud` 1. Under `Management`, select `Environment Settings` 1. Click on the appropriate Management Group, Subscription, or Workspace 1. Click on `Email notifications` 1. In the drop down of the `All users with the following roles` field select `Owner` 1. Click `Save` **Remediate from Azure CLI** Use the below command to set `Send email also to subscription owners` to `On`. ``` az account get-access-token --query {subscription:subscription,accessToken:accessToken} --out tsv | xargs -L1 bash -c 'curl -X PUT -H Authorization: Bearer $1 -H Content-Type: application/json https://management.azure.com/subscriptions/$0/providers/Microsoft.Security/securityContacts/default1?api-version=2017-08-01-preview -d@input.json' ``` Where `input.json` contains the data below, replacing `validEmailAddress` with a single email address or multiple comma-separated email addresses: ``` { id: /subscriptions//providers/Microsoft.Security/securityContacts/default1, name: default1, type: Microsoft.Security/securityContacts, properties: { email: , alertNotifications: On, alertsToAdmins: On, notificationsByRole: Owner } } ```", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Defender for Cloud` 1. Under `Management`, select `Environment Settings` 1. Click on the appropriate Management Group, Subscription, or Workspace 1. Click on `Email notifications` 1. Ensure that `All users with the following roles` is set to `Owner` **Audit from Azure CLI** Ensure the command below returns state of `On` and that `Owner` appears in roles. ``` az account get-access-token --query {subscription:subscription,accessToken:accessToken} --out tsv | xargs -L1 bash -c 'curl -X GET -H Authorization: Bearer $1 -H Content-Type: application/json https://management.azure.com/subscriptions/$0/providers/Microsoft.Security/securityContacts?api-version=2020-01-01-preview'| jq '.[] | select(.name==default).properties.notificationsByRole' ```", + "AdditionalInformation": "Excluding any entries in the input.json properties block disables the specific setting by default.", + "References": "https://docs.microsoft.com/en-us/azure/security-center/security-center-provide-security-contact-details:https://docs.microsoft.com/en-us/rest/api/securitycenter/securitycontacts/list:https://docs.microsoft.com/en-us/rest/api/securitycenter/security-contacts:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-incident-response#ir-2-preparation---setup-incident-notification", + "DefaultValue": "By default, `Owner` is selected" + } + ] + }, + { + "Id": "8.1.13", + "Description": "Ensure 'Additional email addresses' is Configured with a Security Contact Email", + "Checks": [ + "defender_additional_email_configured_with_a_security_contact" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure 'Additional email addresses' is Configured with a Security Contact Email", + "RationaleStatement": "Microsoft Defender for Cloud emails the Subscription Owner to notify them about security alerts. Adding your Security Contact's email address to the 'Additional email addresses' field ensures that your organization's Security Team is included in these alerts. This ensures that the proper people are aware of any potential compromise in order to mitigate the risk in a timely fashion.", + "ImpactStatement": "Security contacts will receive email notifications.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment Settings`. 1. Click on the appropriate Management Group, Subscription, or Workspace. 1. Click on `Email notifications`. 1. Enter a valid security contact email address (or multiple addresses separated by commas) in the `Additional email addresses` field. 1. Click `Save`. **Remediate from Azure CLI** Use the below command to set `Security contact emails` to `On`. ``` az account get-access-token --query {subscription:subscription,accessToken:accessToken} --out tsv | xargs -L1 bash -c 'curl -X PUT -H Authorization: Bearer $1 -H Content-Type: application/json https://management.azure.com/subscriptions/$0/providers/Microsoft.Security/securityContacts/default?api-version=2020-01-01-preview -d@input.json' ``` Where `input.json` contains the data below, replacing `validEmailAddress` with a single email address or multiple comma-separated email addresses: ``` { id: /subscriptions//providers/Microsoft.Security/securityContacts/default, name: default, type: Microsoft.Security/securityContacts, properties: { email: , alertNotifications: On, alertsToAdmins: On } } ```", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment Settings`. 1. Click on the appropriate Management Group, Subscription, or Workspace. 1. Click on `Email notifications`. 1. Ensure that a valid security contact email address is listed in the `Additional email addresses` field. **Audit from Azure CLI** Ensure the output of the below command is not empty and is set with appropriate email ids: ``` az account get-access-token --query {subscription:subscription,accessToken:accessToken} --out tsv | xargs -L1 bash -c 'curl -X GET -H Authorization: Bearer $1 -H Content-Type: application/json https://management.azure.com/subscriptions/$0/providers/Microsoft.Security/securityContacts?api-version=2020-01-01-preview' | jq '.|.[] | select(.name==default)'|jq '.properties.emails' ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [4f4f78b8-e367-4b10-a341-d9a4ad5cf1c7](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4f4f78b8-e367-4b10-a341-d9a4ad5cf1c7) **- Name:** 'Subscriptions should have a contact email address for security issues'", + "AdditionalInformation": "Excluding any entries in the input.json properties block disables the specific setting by default.", + "References": "https://docs.microsoft.com/en-us/azure/security-center/security-center-provide-security-contact-details:https://docs.microsoft.com/en-us/rest/api/securitycenter/securitycontacts/list:https://docs.microsoft.com/en-us/rest/api/securitycenter/security-contacts:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-incident-response#ir-2-preparation---setup-incident-notification", + "DefaultValue": "By default, there are no additional email addresses entered." + } + ] + }, + { + "Id": "8.1.14", + "Description": "Ensure that 'Notify about alerts with the following severity (or higher)' is Enabled", + "Checks": [ + "defender_ensure_notify_alerts_severity_is_high" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that 'Notify about alerts with the following severity (or higher)' is Enabled", + "RationaleStatement": "Enabling security alert emails ensures that security alert emails are sent by Microsoft. This ensures that the right people are aware of any potential security issues and can mitigate the risk.", + "ImpactStatement": "Enabling security alert emails can cause alert fatigue, increasing the risk of missing important alerts. Select an appropriate severity level to manage notifications. Azure aims to reduce alert fatigue by limiting the daily email volume per severity level. Learn more: https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications#email-frequency.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment settings`. 1. Click on the appropriate Subscription. 1. Click on `Email notifications`. 1. Under `Notification types`, check box next to `Notify about alerts with the following severity (or higher)` and select an appropriate severity level from the drop-down menu. 1. Click `Save`. 1. Repeat steps 1-7 for each Subscription requiring remediation. **Remediate from Azure CLI** Use the below command to enable `Send email notification for high severity alerts`: ``` az account get-access-token --query {subscription:subscription,accessToken:accessToken} --out tsv | xargs -L1 bash -c 'curl -X PUT -H Authorization: Bearer $1 -H Content-Type: application/json https://management.azure.com/subscriptions/<$0>/providers/Microsoft.Security/securityContacts/default1?api-version=2017-08-01-preview -d@input.json' ``` Where `input.json` contains the data below, replacing `validEmailAddress` with a single email address or multiple comma-separated email addresses: ``` { id: /subscriptions//providers/Microsoft.Security/securityContacts/default, name: default, type: Microsoft.Security/securityContacts, properties: { email: , alertNotifications: On, alertsToAdmins: On } } ```", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment settings`. 1. Click on the appropriate Subscription. 1. Click on `Email notifications`. 1. Under `Notification types`, ensure that the box next to `Notify about alerts with the following severity (or higher)` is checked, and an appropriate severity level is selected. 1. Repeat steps 1-6 for each Subscription. **Audit from Azure CLI** Including a Subscription ID at the `$0` in `/subscriptions/$0/providers`, ensure the below command returns `state: On`, and that `minimalSeverity` is set to an appropriate severity level: ``` az account get-access-token --query {subscription:subscription,accessToken:accessToken} --out tsv | xargs -L1 bash -c 'curl -X GET -H Authorization: Bearer $1 -H Content-Type: application/json https://management.azure.com/subscriptions/$0/providers/Microsoft.Security/securityContacts?api-version=2020-01-01-preview' | jq '.|.[] | select(.name==default)'|jq '.properties.alertNotifications' ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [6e2593d9-add6-4083-9c9b-4b7d2188c899](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F6e2593d9-add6-4083-9c9b-4b7d2188c899) **- Name:** 'Email notification for high severity alerts should be enabled'", + "AdditionalInformation": "Excluding any entries in the `input.json` properties block disables the specific setting by default. This recommendation has been updated to reflect recent changes to Microsoft REST APIs for getting and updating security contact information.", + "References": "https://docs.microsoft.com/en-us/azure/security-center/security-center-provide-security-contact-details:https://docs.microsoft.com/en-us/rest/api/securitycenter/security-contacts:https://docs.microsoft.com/en-us/rest/api/securitycenter/securitycontacts/list:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-incident-response#ir-2-preparation---setup-incident-notification", + "DefaultValue": "By default, subscription owners receive email notifications for high-severity alerts." + } + ] + }, + { + "Id": "8.1.15", + "Description": "Ensure that 'Notify about attack paths with the following risk level (or higher)' is Enabled", + "Checks": [ + "defender_attack_path_notifications_properly_configured" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that 'Notify about attack paths with the following risk level (or higher)' is Enabled", + "RationaleStatement": "Enabling attack path emails ensures that attack path emails are sent by Microsoft. This ensures that the right people are aware of any potential security issues and can mitigate the risk.", + "ImpactStatement": "Enabling attack path emails can cause alert fatigue, increasing the risk of missing important alerts. Select an appropriate risk level to manage notifications. Azure aims to reduce alert fatigue by limiting the daily email volume per risk level. Learn more: https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications#email-frequency.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment settings`. 1. Click on the appropriate Subscription. 1. Click on `Email notifications`. 1. Under Notification types, check the box next to `Notify about attack paths with the following risk level (or higher)`, and select an appropriate risk level from the drop-down menu. 1. Repeat steps 1-6 for each Subscription.", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment settings`. 1. Click on the appropriate Subscription. 1. Click on `Email notifications`. 1. Under Notification types, ensure that the box next to `Notify about attack paths with the following risk level (or higher)` is checked, and an appropriate risk level is selected. 1. Repeat steps 1-6 for each Subscription. **Audit from Azure CLI** Including a Subscription ID at the `$0` in `/subscriptions/$0/providers`, ensure the below command returns `sourceType: AttackPath`, and that `minimalRiskLevel` is set to an appropriate risk level: ``` az account get-access-token --query {subscription:subscription,accessToken:accessToken} --out tsv | xargs -L1 bash -c 'curl -X GET -H Authorization: Bearer $1 -H Content-Type: application/json https://management.azure.com/subscriptions/$0/providers/Microsoft.Security/securityContacts?api-version=2023-12-01-preview' | jq '.|.[]' ```", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications:https://learn.microsoft.com/en-us/azure/defender-for-cloud/how-to-manage-attack-path:https://learn.microsoft.com/en-us/azure/defender-for-cloud/concept-attack-path", + "DefaultValue": "" + } + ], + "ConfigRequirements": [ + { + "Check": "defender_attack_path_notifications_properly_configured", + "ConfigKey": "defender_attack_path_minimal_risk_level", + "Operator": "in", + "Value": [ + "Low", + "Medium", + "High" + ] + } + ] + }, + { + "Id": "8.1.16", + "Description": "Ensure that Microsoft Defender External Attack Surface Monitoring (EASM) is Enabled", + "Checks": [], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that Microsoft Defender External Attack Surface Monitoring (EASM) is Enabled", + "RationaleStatement": "This tool can monitor the externally exposed resources of an organization, provide valuable insights, and export these findings in a variety of formats (including CSV) for use in vulnerability management operations and red/purple team exercises.", + "ImpactStatement": "Microsoft Defender EASM workspaces are currently available as Azure Resources with a 30-day free trial period but can quickly accrue significant charges. The costs are calculated daily as (Number of billable inventory items) x (item cost per day; approximately: $0.017). Estimated cost is not provided within the tool, and users are strongly advised to contact their Microsoft sales representative for pricing and set a calendar reminder for the end of the trial period. For an EASM workspace having an Inventory of 5k-10k billable items (IP addresses, hostnames, SSL certificates, etc) a typical cost might be approximately $85-170 per day or $2500-5000 USD/month at the time of publication. If the workspace is deleted by the last day of a free trial period, no charges are billed.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender EASM`. 1. Click `+ Create`. 1. Under `Project details`, select a subscription. 1. Select or create a resource group. 1. Under `Instance details`, enter a name for the workspace. 1. Select a region. 1. Click `Review + create`. 1. Click `Create`. 1. Once the deployment has completed, go to `Microsoft Defender EASM`. 1. Click the workspace name. 1. Configure the workspace appropriately for your environment and organization.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender EASM`. 1. Ensure that at least one Microsoft Defender EASM workspace is listed. 1. Click the name of a workspace. 1. Ensure the workspace is configured appropriately for your environment and organization. 1. Repeat steps 3-4 for each workspace.", + "AdditionalInformation": "Microsoft added its Defender for External Attack Surface management (EASM) offering to Azure following its 2022 acquisition of EASM SaaS tool company RiskIQ.", + "References": "https://learn.microsoft.com/en-us/azure/external-attack-surface-management/:https://learn.microsoft.com/en-us/azure/external-attack-surface-management/deploying-the-defender-easm-azure-resource:https://www.microsoft.com/en-us/security/blog/2022/08/02/microsoft-announces-new-solutions-for-threat-intelligence-and-attack-surface-management/", + "DefaultValue": "Microsoft Defender EASM is an optional, paid Azure Resource that must be created and configured inside a Subscription and Resource Group." + } + ] + }, + { + "Id": "8.2.1", + "Description": "Ensure That Microsoft Defender for IoT Hub Is Set To 'On'", + "Checks": [ + "defender_ensure_iot_hub_defender_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.2 Microsoft Defender for IoT", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure That Microsoft Defender for IoT Hub Is Set To 'On'", + "RationaleStatement": "IoT devices are very rarely patched and can be potential attack vectors for enterprise networks. Updating their network configuration to use a central security hub allows for detection of these breaches.", + "ImpactStatement": "Enabling Microsoft Defender for IoT will incur additional charges dependent on the level of usage.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `IoT Hub`. 2. Select an `IoT Hub` to validate. 3. Select `Overview` in `Defender for IoT`. 4. Click on `Secure your IoT solution`, and complete the onboarding.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `IoT Hub`. 2. Select an `IoT Hub` to validate. 3. Select `Overview` in `Defender for IoT`. 4. The Threat prevention and Threat detection screen will appear, if `Defender for IoT` is Enabled.", + "AdditionalInformation": "There are additional configurations for Microsoft Defender for IoT that allow for types of deployments called hybrid or local. Both run on your physical infrastructure. These are complicated setups and are primarily outside of the scope of a purely Azure benchmark. Please see the references to consider these options for your organization.", + "References": "https://azure.microsoft.com/en-us/services/iot-defender/#overview:https://docs.microsoft.com/en-us/azure/defender-for-iot/:https://azure.microsoft.com/en-us/pricing/details/iot-defender/:https://docs.microsoft.com/en-us/security/benchmark/azure/baselines/defender-for-iot-security-baseline:https://docs.microsoft.com/en-us/cli/azure/iot?view=azure-cli-latest:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities:https://learn.microsoft.com/en-us/azure/defender-for-iot/device-builders/quickstart-onboard-iot-hub", + "DefaultValue": "By default, Microsoft Defender for IoT is not enabled." + } + ] + }, + { + "Id": "8.3.1", + "Description": "Ensure that the Expiration Date is Set for all Keys in Key Vaults using RBAC", + "Checks": [ + "keyvault_rbac_key_expiration_set" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the Expiration Date is Set for all Keys in Key Vaults using RBAC", + "RationaleStatement": "Azure Key Vault enables users to store and use cryptographic keys within the Microsoft Azure environment. The `exp` (expiration date) attribute identifies the expiration date on or after which the key MUST NOT be used for encryption of new data, wrapping of new keys, and signing. By default, keys never expire. It is thus recommended that keys be rotated in the key vault and set an explicit expiration date for all keys to help enforce the key rotation. This ensures that the keys cannot be used beyond their assigned lifetimes.", + "ImpactStatement": "Keys cannot be used beyond their assigned expiration dates respectively. Keys need to be rotated periodically wherever they are used.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Key vaults`. 2. For each Key vault, click on `Keys`. 3. In the main pane, ensure that an appropriate `Expiration date` is set for any keys that are `Enabled`. **Remediate from Azure CLI** Update the `Expiration date` for the key using the below command: ``` az keyvault key set-attributes --name --vault-name --expires Y-m-d'T'H:M:S'Z' ``` **Note:** To view the expiration date on all keys in a Key Vault using Microsoft API, the List Key permission is required. To update the expiration date for the keys: 1. Go to the Key vault, click on Access Control (IAM). 2. Click on Add role assignment and assign the role of Key Vault Crypto Officer to the appropriate user. **Remediate from PowerShell** ``` Set-AzKeyVaultKeyAttribute -VaultName -Name -Expires ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Key vaults`. 2. For each Key vault, click on `Keys`. 3. In the main pane, ensure that an appropriate `Expiration date` is set for any keys that are `Enabled`. **Audit from Azure CLI** Get a list of all the key vaults in your Azure environment by running the following command: ``` az keyvault list ``` Then for each key vault listed ensure that the output of the below command contains Key ID (kid), enabled status as `true` and Expiration date (expires) is not empty or null: ``` az keyvault key list --vault-name --query '[*].{kid:kid,enabled:attributes.enabled,expires:attributes.expires}' ``` **Audit from PowerShell** Retrieve a list of Azure Key vaults: ``` Get-AzKeyVault ``` For each Key vault run the following command to determine which vaults are configured to use RBAC. ``` Get-AzKeyVault -VaultName ``` For each Key vault with the `EnableRbacAuthorizatoin` setting set to `True`, run the following command. ``` Get-AzKeyVaultKey -VaultName ``` Make sure the `Expires` setting is configured with a value as appropriate wherever the `Enabled` setting is set to `True`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [152b15f7-8e1f-4c1f-ab71-8c010ba5dbc0](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F152b15f7-8e1f-4c1f-ab71-8c010ba5dbc0) **- Name:** 'Key Vault keys should have an expiration date'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/key-vault/key-vault-whatis:https://docs.microsoft.com/en-us/rest/api/keyvault/about-keys--secrets-and-certificates#key-vault-keys:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-6-use-a-secure-key-management-process:https://docs.microsoft.com/en-us/powershell/module/az.keyvault/set-azkeyvaultkeyattribute?view=azps-0.10.0", + "DefaultValue": "By default, keys do not expire." + } + ] + }, + { + "Id": "8.3.2", + "Description": "Ensure that the Expiration Date is set for All Keys in Key Vaults using access policies (legacy)", + "Checks": [ + "keyvault_key_expiration_set_in_non_rbac" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the Expiration Date is set for All Keys in Key Vaults using access policies (legacy)", + "RationaleStatement": "Azure Key Vault enables users to store and use cryptographic keys within the Microsoft Azure environment. The `exp` (expiration date) attribute identifies the expiration date on or after which the key MUST NOT be used for a cryptographic operation. By default, keys never expire. It is thus recommended that keys be rotated in the key vault and set an explicit expiration date for all keys. This ensures that the keys cannot be used beyond their assigned lifetimes.", + "ImpactStatement": "Keys cannot be used beyond their assigned expiration dates respectively. Keys need to be rotated periodically wherever they are used.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Key vaults`. 2. For each Key vault, click on `Keys`. 3. In the main pane, ensure that the status of the key is `Enabled`. 4. For each enabled key, ensure that an appropriate `Expiration date` is set. **Remediate from Azure CLI** Update the `Expiration date` for the key using the below command: ``` az keyvault key set-attributes --name --vault-name --expires Y-m-d'T'H:M:S'Z' ``` **Note:** To view the expiration date on all keys in a Key Vault using Microsoft API, the List Key permission is required. To update the expiration date for the keys: 1. Go to Key vault, click on `Access policies`. 2. Click on `Create` and add an access policy with the `Update` permission (in the Key Permissions - Key Management Operations section). **Remediate from PowerShell** ``` Set-AzKeyVaultKeyAttribute -VaultName -Name -Expires ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Key vaults`. 2. For each Key vault, click on `Keys`. 3. In the main pane, ensure that the status of the key is `Enabled`. 4. For each enabled key, ensure that an appropriate `Expiration date` is set. **Audit from Azure CLI** Get a list of all the key vaults in your Azure environment by running the following command: ``` az keyvault list ``` For each key vault, ensure that the output of the below command contains Key ID (kid), enabled status as `true` and Expiration date (expires) is not empty or null: ``` az keyvault key list --vault-name --query '[*].{kid:kid,enabled:attributes.enabled,expires:attributes.expires}' ``` **Audit from PowerShell** Retrieve a list of Azure Key vaults: ``` Get-AzKeyVault ``` For each Key vault, run the following command to determine which vaults are configured to not use RBAC: ``` Get-AzKeyVault -VaultName ``` For each Key vault with the `EnableRbacAuthorizatoin` setting set to `False` or empty, run the following command. ``` Get-AzKeyVaultKey -VaultName ``` Make sure the `Expires` setting is configured with a value as appropriate wherever the `Enabled` setting is set to `True`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [152b15f7-8e1f-4c1f-ab71-8c010ba5dbc0](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F152b15f7-8e1f-4c1f-ab71-8c010ba5dbc0) **- Name:** 'Key Vault keys should have an expiration date'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/key-vault/key-vault-whatis:https://docs.microsoft.com/en-us/rest/api/keyvault/about-keys--secrets-and-certificates#key-vault-keys:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-6-use-a-secure-key-management-process:https://docs.microsoft.com/en-us/powershell/module/az.keyvault/set-azkeyvaultkeyattribute?view=azps-0.10.0", + "DefaultValue": "By default, keys do not expire." + } + ] + }, + { + "Id": "8.3.3", + "Description": "Ensure that the Expiration Date is set for All Secrets in Key Vaults using RBAC", + "Checks": [ + "keyvault_rbac_secret_expiration_set" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the Expiration Date is set for All Secrets in Key Vaults using RBAC", + "RationaleStatement": "The Azure Key Vault enables users to store and keep secrets within the Microsoft Azure environment. Secrets in the Azure Key Vault are octet sequences with a maximum size of 25k bytes each. The `exp` (expiration date) attribute identifies the expiration date on or after which the secret MUST NOT be used. By default, secrets never expire. It is thus recommended to rotate secrets in the key vault and set an explicit expiration date for all secrets. This ensures that the secrets cannot be used beyond their assigned lifetimes.", + "ImpactStatement": "Secrets cannot be used beyond their assigned expiry date respectively. Secrets need to be rotated periodically wherever they are used.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Key vaults`. 2. For each Key vault, click on `Secrets`. 3. In the main pane, ensure that the status of the secret is `Enabled`. 4. For each enabled secret, ensure that an appropriate `Expiration date` is set. **Remediate from Azure CLI** Update the Expiration date for the secret using the below command: ``` az keyvault secret set-attributes --name --vault-name --expires Y-m-d'T'H:M:S'Z' ``` Note: To view the expiration date on all secrets in a Key Vault using Microsoft API, the `List Secret` permission is required. To update the expiration date for the secrets: 1. Go to the Key vault, click on `Access Control (IAM)`. 2. Click on `Add role assignment` and assign the role of `Key Vault Secrets Officer` to the appropriate user. **Remediate from PowerShell** ``` Set-AzKeyVaultSecretAttribute -VaultName -Name -Expires ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Key vaults`. 2. For each Key vault, click on `Secrets`. 3. In the main pane, ensure that the status of the secret is `Enabled`. 4. For each enabled secret, ensure that an appropriate `Expiration date` is set. **Audit from Azure CLI** Ensure that the output of the below command contains ID (id), enabled status as `true` and Expiration date (expires) is not empty or null: ``` az keyvault secret list --vault-name --query '[*].{kid:kid,enabled:attributes.enabled,expires:attributes.expires}' ``` **Audit from PowerShell** Retrieve a list of Key vaults: ``` Get-AzKeyVault ``` For each Key vault, run the following command to determine which vaults are configured to use RBAC: ``` Get-AzKeyVault -VaultName ``` For each Key vault with the `EnableRbacAuthorization` setting set to `True`, run the following command: ``` Get-AzKeyVaultSecret -VaultName ``` Make sure the `Expires` setting is configured with a value as appropriate wherever the `Enabled` setting is set to `True`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [98728c90-32c7-4049-8429-847dc0f4fe37](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F98728c90-32c7-4049-8429-847dc0f4fe37) **- Name:** 'Key Vault secrets should have an expiration date'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/key-vault/key-vault-whatis:https://docs.microsoft.com/en-us/rest/api/keyvault/about-keys--secrets-and-certificates#key-vault-secrets:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-6-use-a-secure-key-management-process:https://docs.microsoft.com/en-us/powershell/module/az.keyvault/set-azkeyvaultsecretattribute?view=azps-0.10.0", + "DefaultValue": "By default, secrets do not expire." + } + ] + }, + { + "Id": "8.3.4", + "Description": "Ensure that the Expiration Date is set for All Secrets in Key Vaults using access policies (legacy)", + "Checks": [ + "keyvault_non_rbac_secret_expiration_set" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the Expiration Date is set for All Secrets in Key Vaults using access policies (legacy)", + "RationaleStatement": "The Azure Key Vault enables users to store and keep secrets within the Microsoft Azure environment. Secrets in the Azure Key Vault are octet sequences with a maximum size of 25k bytes each. The `exp` (expiration date) attribute identifies the expiration date on or after which the secret MUST NOT be used. By default, secrets never expire. It is thus recommended to rotate secrets in the key vault and set an explicit expiration date for all secrets. This ensures that the secrets cannot be used beyond their assigned lifetimes.", + "ImpactStatement": "Secrets cannot be used beyond their assigned expiry date respectively. Secrets need to be rotated periodically wherever they are used.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Key vaults`. 2. For each Key vault, click on `Secrets`. 3. In the main pane, ensure that the status of the secret is `Enabled`. 4. Set an appropriate `Expiration date` on all secrets. **Remediate from Azure CLI** Update the `Expiration date` for the secret using the below command: ``` az keyvault secret set-attributes --name --vault-name --expires Y-m-d'T'H:M:S'Z' ``` Note: To view the expiration date on all secrets in a Key Vault using Microsoft API, the `List` Secret permission is required. To update the expiration date for the secrets: 1. Go to Key vault, click on `Access policies`. 2. Click on `Create` and add an access policy with the `Update` permission (in the Secret Permissions - Secret Management Operations section). **Remediate from PowerShell** For each Key vault with the `EnableRbacAuthorization` setting set to `False` or empty, run the following command. ``` Set-AzKeyVaultSecret -VaultName -Name -Expires ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Key vaults`. 2. For each Key vault, click on `Secrets`. 3. In the main pane, ensure that the status of the secret is `Enabled`. 4. Set an appropriate `Expiration date` on all secrets. **Audit from Azure CLI** Get a list of all the key vaults in your Azure environment by running the following command: ``` az keyvault list ``` For each key vault, ensure that the output of the below command contains ID (id), enabled status as `true` and Expiration date (expires) is not empty or null: ``` az keyvault secret list --vault-name --query '[*].{kid:kid,enabled:attributes.enabled,expires:attributes.expires}' ``` **Audit from PowerShell** Retrieve a list of Key vaults: ``` Get-AzKeyVault ``` For each Key vault run the following command to determine which vaults are configured to use RBAC: ``` Get-AzKeyVault -VaultName ``` For each Key Vault with the `EnableRbacAuthorization` setting set to `False` or empty, run the following command. ``` Get-AzKeyVaultSecret -VaultName ``` Make sure the `Expires` setting is configured with a value as appropriate wherever the `Enabled` setting is set to `True`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [98728c90-32c7-4049-8429-847dc0f4fe37](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F98728c90-32c7-4049-8429-847dc0f4fe37) **- Name:** 'Key Vault secrets should have an expiration date'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/key-vault/key-vault-whatis:https://docs.microsoft.com/en-us/rest/api/keyvault/about-keys--secrets-and-certificates#key-vault-secrets:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-6-use-a-secure-key-management-process:https://docs.microsoft.com/en-us/powershell/module/az.keyvault/set-azkeyvaultsecret?view=azps-7.4.0", + "DefaultValue": "By default, secrets do not expire." + } + ] + }, + { + "Id": "8.3.5", + "Description": "Ensure 'Purge protection' is Set to 'Enabled'", + "Checks": [ + "keyvault_recoverable" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure 'Purge protection' is Set to 'Enabled'", + "RationaleStatement": "Users may accidentally run delete/purge commands on a key vault, or an attacker or malicious user may do so deliberately in order to cause disruption. Deleting or purging a key vault leads to immediate data loss, as keys encrypting data and secrets/certificates allowing access/services will become inaccessible. Enabling purge protection ensures that even if a key vault is deleted, the key vault and its objects remain recoverable during the configurable retention period. If no action is taken, the key vault and its objects will be purged once the retention period elapses.", + "ImpactStatement": "Once purge protection is enabled for a key vault, it cannot be disabled.", + "RemediationProcedure": "**Note:** Once enabled, purge protection cannot be disabled. **Remediate from Azure Portal** 1. Go to `Key Vaults`. 2. Click the name of a key vault. 3. Under `Settings`, click `Properties`. 4. Select the radio button next to `Enable purge protection (enforce a mandatory retention period for deleted vaults and vault objects)`. 5. Click `Save`. 6. Repeat steps 1-5 for each key vault requiring remediation. **Remediate from Azure CLI** For each key vault requiring remediation, run the following command to enable purge protection: ``` az resource update --resource-group --name --resource-type \"Microsoft.KeyVault/vaults\" --set properties.enablePurgeProtection=true ``` **Remediate from PowerShell** For each key vault requiring remediation, run the following command to enable purge protection: ``` Update-AzKeyVault -ResourceGroupName -VaultName -EnablePurgeProtection ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Key Vaults`. 2. Click the name of a key vault. 3. Under `Settings`, click `Properties`. 4. Next to `Purge protection`, ensure that `Enable purge protection (enforce a mandatory retention period for deleted vaults and vault objects)` is selected. 5. Repeat steps 1-4 for each key vault. **Audit from Azure CLI** Run the following command to list key vaults: ``` az resource list --query \"[?type=='Microsoft.KeyVault/vaults']\" ``` For each key vault, run the following command to get the purge protection setting: ``` az resource show --resource-group --name --resource-type \"Microsoft.KeyVault/vaults\" --query properties.enablePurgeProtection ``` Ensure that `true` is returned. **Audit from PowerShell** Run the following command to list key vaults: ``` Get-AzKeyVault ``` For each key vault, run the following command to get the key vault details: ``` Get-AzKeyVault -ResourceGroupName -VaultName ``` Ensure `Purge Protection Enabled?` is set to `True`. **Audit from Azure Policy** - **Policy ID:** 0b60c0b2-2dc2-4e1c-b5c9-abbed971de53 - **Name:** 'Key vaults should have deletion protection enabled'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/key-vault/general/key-vault-recovery:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-8-define-and-implement-backup-and-recovery-strategy:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-8-ensure-security-of-key-and-certificate-repository", + "DefaultValue": "Purge protection is disabled by default." + } + ] + }, + { + "Id": "8.3.6", + "Description": "Ensure that Role Based Access Control for Azure Key Vault is Enabled", + "Checks": [ + "keyvault_rbac_enabled" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure that Role Based Access Control for Azure Key Vault is Enabled", + "RationaleStatement": "The new RBAC permissions model for Key Vaults enables a much finer grained access control for key vault secrets, keys, certificates, etc., than the vault access policy. This in turn will permit the use of privileged identity management over these roles, thus securing the key vaults with JIT Access management.", + "ImpactStatement": "Implementation needs to be properly designed from the ground up, as this is a fundamental change to the way key vaults are accessed/managed. Changing permissions to key vaults will result in loss of service as permissions are re-applied. For the least amount of downtime, map your current groups and users to their corresponding permission needs.", + "RemediationProcedure": "**Remediate from Azure Portal** Key Vaults can be configured to use `Azure role-based access control` on creation. For existing Key Vaults: 1. From Azure Home open the Portal Menu in the top left corner 2. Select `Key Vaults` 3. Select a Key Vault to audit 4. Select `Access configuration` 5. Set the Permission model radio button to `Azure role-based access control`, taking note of the warning message 6. Click `Save` 7. Select `Access Control (IAM)` 8. Select the `Role Assignments` tab 9. Reapply permissions as needed to groups or users **Remediate from Azure CLI** To enable RBAC Authorization for each Key Vault, run the following Azure CLI command: ``` az keyvault update --resource-group --name --enable-rbac-authorization true ``` **Remediate from PowerShell** To enable RBAC authorization on each Key Vault, run the following PowerShell command: ``` Update-AzKeyVault -ResourceGroupName -VaultName -EnableRbacAuthorization $True ```", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home open the Portal Menu in the top left corner 2. Select Key Vaults 3. Select a Key Vault to audit 4. Select Access configuration 5. Ensure the Permission Model radio button is set to `Azure role-based access control` **Audit from Azure CLI** Run the following command for each Key Vault in each Resource Group: ``` az keyvault show --resource-group --name ``` Ensure the `enableRbacAuthorization` setting is set to `true` within the output of the above command. **Audit from PowerShell** Run the following PowerShell command: ``` Get-AzKeyVault -Vaultname -ResourceGroupName ``` Ensure the `Enabled For RBAC Authorization` setting is set to `True` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [12d4fa5e-1f9f-4c21-97a9-b99b3c6611b5](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F12d4fa5e-1f9f-4c21-97a9-b99b3c6611b5) **- Name:** 'Azure Key Vault should use RBAC permission model'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-gb/azure/key-vault/general/rbac-migration#vault-access-policy-to-azure-rbac-migration-steps:https://docs.microsoft.com/en-gb/azure/role-based-access-control/role-assignments-portal?tabs=current:https://docs.microsoft.com/en-gb/azure/role-based-access-control/overview:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-8-ensure-security-of-key-and-certificate-repository", + "DefaultValue": "The default value for Access control in Key Vaults is Vault Policy." + } + ] + }, + { + "Id": "8.3.7", + "Description": "Ensure Public Network Access is Disabled", + "Checks": [ + "keyvault_private_endpoints" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure Public Network Access is Disabled", + "RationaleStatement": "Disabling public network access improves security by ensuring that a service is not exposed on the public internet. Removing a point of interconnection from the internet edge to your key vault can strengthen the network security boundary of your system and reduce the risk of exposing the control plane or vault objects to untrusted clients. Although Azure resources are never truly isolated from the public internet, disabling the public endpoint removes a line of sight from the public internet and increases the effort required for an attack.", + "ImpactStatement": "NOTE: Prior to disabling public network access, it is strongly recommended that, for each key vault, either: virtual network integration is completed OR private endpoints/links are set up as described in 'Ensure Private Endpoints are used to access Azure Key Vault.' Disabling public network access restricts access to the service. This enhances security but will require the configuration of a virtual network and/or private endpoints for any services or users needing access within trusted networks.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Key vaults`. 2. Click the name of a key vault. 3. Under `Settings`, click `Networking`. 4. Under `Firewalls and virtual networks`, next to `Allow access from:`, click the radio button next to `Disable public access`. 5. Click `Apply`. 6. Repeat steps 1-5 for each key vault requiring remediation. **Remediate from Azure CLI** For each key vault requiring remediation, run the following command to disable public network access: ``` az keyvault update --resource-group --name --public-network-access Disabled ``` **Remediate from PowerShell** For each key vault requiring remediation, run the following command to disable public network access: ``` Update-AzKeyVault -ResourceGroupName -VaultName -PublicNetworkAccess \"Disabled\" ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Key vaults`. 2. Click the name of a key vault. 3. Under `Settings`, click `Networking`. 4. Under `Firewalls and virtual networks`, ensure that `Allow access from:` is set to `Disable public access`. 5. Repeat steps 1-4 for each key vault. **Audit from Azure CLI** Run the following command to list key vaults: ``` az keyvault list ``` For each key vault, run the following command to get the public network access setting: ``` az keyvault show --resource-group --name --query properties.publicNetworkAccess ``` Ensure that `Disabled` is returned. **Audit from PowerShell** Run the following command to list key vaults: ``` Get-AzKeyVault ``` Run the following command to get the key vault in a resource group with a given name: ``` $vault = Get-AzKeyVault -ResourceGroupName -Name ``` Run the following command to get the public network access setting for the key vault: ``` $vault.PublicNetworkAccess ``` Ensure that `Disabled` is returned. Repeat for each key vault. **Audit from Azure Policy** - **Policy ID:** 405c5871-3e91-4644-8a63-58e19d68ff5b - **Name:** 'Azure Key Vault should disable public network access'", + "AdditionalInformation": "This Common Reference Recommendation is referenced in the following Service Recommendations: - Storage Services > Storage Accounts > Networking > **Ensure that 'Public Network Access' is 'Disabled' for storage accounts**", + "References": "https://learn.microsoft.com/en-us/azure/key-vault/general/network-security:https://learn.microsoft.com/en-us/azure/key-vault/general/private-link-service", + "DefaultValue": "Public network access is enabled by default." + } + ] + }, + { + "Id": "8.3.8", + "Description": "Ensure Private Endpoints are Used to Access Azure Key Vault", + "Checks": [ + "keyvault_private_endpoints" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Private Endpoints are Used to Access Azure Key Vault", + "RationaleStatement": "Private endpoints will keep network requests to Azure Key Vault limited to the endpoints attached to the resources that are whitelisted to communicate with each other. Assigning the Key Vault to a network without an endpoint will allow other resources on that network to view all traffic from the Key Vault to its destination. In spite of the complexity in configuration, this is recommended for high security secrets.", + "ImpactStatement": "Incorrect or poorly-timed changing of network configuration could result in service interruption. There are also additional costs tiers for running a private endpoint per petabyte or more of networking traffic.", + "RemediationProcedure": "**Please see the additional information about the requirements needed before starting this remediation procedure.** **Remediate from Azure Portal** 1. From Azure Home open the Portal Menu in the top left. 2. Select Key Vaults. 3. Select a Key Vault to audit. 4. Select `Networking` in the left column. 5. Select `Private endpoint connections` from the top row. 6. Select `+ Create`. 7. Select the subscription the Key Vault is within, and other desired configuration. 8. Select `Next`. 9. For resource type select `Microsoft.KeyVault/vaults`. 10. Select the Key Vault to associate the Private Endpoint with. 11. Select `Next`. 12. In the `Virtual Networking` field, select the network to assign the Endpoint. 13. Select other configuration options as desired, including an existing or new application security group. 14. Select `Next`. 15. Select the private DNS the Private Endpoints will use. 16. Select `Next`. 17. Optionally add `Tags`. 18. Select `Next : Review + Create`. 19. Review the information and select `Create`. Follow the Audit Procedure to determine if it has successfully applied. 20. Repeat steps 3-19 for each Key Vault. **Remediate from Azure CLI** 1. To create an endpoint, run the following command: ``` az network private-endpoint create --resource-group --subnet --name --private-connection-resource-id /subscriptions//resourceGroups//providers/Microsoft.KeyVault/vaults/ --group-ids vault --connection-name --location --manual-request ``` 2. To manually approve the endpoint request, run the following command: ``` az keyvault private-endpoint-connection approve --resource-group --vault-name –name ``` 3. Determine the Private Endpoint's IP address to connect the Key Vault to the Private DNS you have previously created: 4. Look for the property networkInterfaces then id; the value must be placed in the variable within step 7. ``` az network private-endpoint show -g -n ``` 5. Look for the property networkInterfaces then id; the value must be placed on in step 7. ``` az network nic show --ids ``` 6. Create a Private DNS record within the DNS Zone you created for the Private Endpoint: ``` az network private-dns record-set a add-record -g -z privatelink.vaultcore.azure.net -n -a ``` 7. nslookup the private endpoint to determine if the DNS record is correct: ``` nslookup .vault.azure.net nslookup .privatelink.vaultcore.azure.n ```", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home open the Portal Menu in the top left. 2. Select Key Vaults. 3. Select a Key Vault to audit. 4. Select `Networking` in the left column. 5. Select `Private endpoint connections` from the top row. 6. View if there is an endpoint attached. **Audit from Azure CLI** Run the following command within a subscription for each Key Vault you wish to audit. ``` az keyvault show --name ``` Ensure that `privateEndpointConnections` is not `null`. **Audit from PowerShell** Run the following command within a subscription for each Key Vault you wish to audit. ``` Get-AzPrivateEndpointConnection -PrivateLinkResourceId '/subscriptions//resourceGroups//providers/Microsoft.KeyVault/vaults//' ``` Ensure that the response contains details of a private endpoint. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [a6abeaec-4d90-4a02-805f-6b26c4d3fbe9](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fa6abeaec-4d90-4a02-805f-6b26c4d3fbe9) **- Name:** 'Azure Key Vaults should use private link'", + "AdditionalInformation": "This recommendation assumes that you have created a Resource Group containing a Virtual Network that the services are already associated with and configured private DNS. A Bastion on the virtual network is also required, and the service to which you are connecting must already have a Private Endpoint. For information concerning the installation of these services, please see the attached documentation. Microsoft's own documentation lists the requirements as: A Key Vault. An Azure virtual network. A subnet in the virtual network. Owner or contributor permissions for both the Key Vault and the virtual network.", + "References": "https://docs.microsoft.com/en-us/azure/private-link/private-endpoint-overview:https://docs.microsoft.com/en-us/azure/storage/common/storage-private-endpoints:https://azure.microsoft.com/en-us/pricing/details/private-link/:https://docs.microsoft.com/en-us/azure/key-vault/general/private-link-service?tabs=portal:https://docs.microsoft.com/en-us/azure/virtual-network/quick-create-portal:https://docs.microsoft.com/en-us/azure/private-link/tutorial-private-endpoint-storage-portal:https://docs.microsoft.com/en-us/azure/bastion/bastion-overview:https://docs.microsoft.com/azure/dns/private-dns-getstarted-cli#create-an-additional-dns-record:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-8-ensure-security-of-key-and-certificate-repository", + "DefaultValue": "By default, Private Endpoints are not enabled for any services within Azure." + } + ] + }, + { + "Id": "8.3.9", + "Description": "Ensure Automatic Key Rotation is Enabled within Azure Key Vault", + "Checks": [ + "keyvault_key_rotation_enabled" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Automatic Key Rotation is Enabled within Azure Key Vault", + "RationaleStatement": "Automatic key rotation reduces risk by ensuring that keys are rotated without manual intervention. Azure and NIST recommend that keys be rotated every two years or less. Refer to 'Table 1: Suggested cryptoperiods for key types' on page 46 of the following document for more information: https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-57pt1r5.pdf.", + "ImpactStatement": "There is an additional cost for each scheduled key rotation.", + "RemediationProcedure": "**Note:** Azure CLI and PowerShell use the ISO8601 duration format for time spans. The format is `P(Y,M,D)`. The leading P is required and is referred to as `period`. The `(Y,M,D)` are for the duration of Year, Month, and Day, respectively. A time frame of 2 years, 2 months, 2 days would be `P2Y2M2D`. For Azure CLI and PowerShell, it is easiest to supply the policy flags in a `.json file`, for example: ``` { lifetimeActions: [ { trigger: { timeAfterCreate: P(Y,M,D), timeBeforeExpiry : null }, action: { type: Rotate } }, { trigger: { timeBeforeExpiry : P(Y,M,D) }, action: { type: Notify } } ], attributes: { expiryTime: P(Y,M,D) } } ``` **Remediate from Azure Portal** 1. Go to `Key Vaults`. 1. Select a Key Vault. 1. Under `Objects`, select `Keys`. 1. Select a key. 1. From the top row, select `Rotation policy`. 1. Select an appropriate `Expiry time`. 1. Set `Enable auto rotation` to `Enabled`. 1. Set an appropriate `Rotation option` and `Rotation time`. 1. Optionally, set a `Notification time`. 1. Click `Save`. 1. Repeat steps 1-10 for each Key Vault and Key. **Remediate from Azure CLI** Run the following command for each key to enable automatic rotation: ``` az keyvault key rotation-policy update --vault-name --name --value ``` **Remediate from PowerShell** Run the following command for each key to enable automatic rotation: ``` Set-AzKeyVaultKeyRotationPolicy -VaultName -Name -PolicyPath ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Key Vaults`. 1. Select a Key Vault. 1. Under `Objects`, select `Keys`. 1. Select a key. 1. From the top row, select `Rotation policy`. 1. Ensure `Enable auto rotation` is set to `Enabled`. 1. Ensure the `Rotation time` is set to an appropriate value. 1. Repeat steps 1-7 for each Key Vault and Key. **Audit from Azure CLI** Run the following command: ``` az keyvault key rotation-policy show --vault-name --name ``` Ensure that the response contains a `lifetimeAction` of `Rotate` and that `timeAfterCreate` is set to an appropriate value. **Audit from PowerShell** Run the following command: ``` Get-AzKeyVaultKeyRotationPolicy -VaultName -Name ``` Ensure that the response contains a `LifetimeAction` of `Rotate` and that `TimeAfterCreate` is set to an appropriate value. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [d8cf8476-a2ec-4916-896e-992351803c44](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fd8cf8476-a2ec-4916-896e-992351803c44) **- Name:** 'Keys should have a rotation policy ensuring that their rotation is scheduled within the specified number of days after creation.'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/key-vault/keys/how-to-configure-key-rotation:https://docs.microsoft.com/en-us/azure/storage/common/customer-managed-keys-overview#update-the-key-version:https://docs.microsoft.com/en-us/azure/virtual-machines/windows/disks-enable-customer-managed-keys-powershell#set-up-an-azure-key-vault-and-diskencryptionset-optionally-with-automatic-key-rotation:https://azure.microsoft.com/en-us/updates/public-preview-automatic-key-rotation-of-customermanaged-keys-for-encrypting-azure-managed-disks/:https://docs.microsoft.com/en-us/cli/azure/keyvault/key/rotation-policy?view=azure-cli-latest#az-keyvault-key-rotation-policy-update:https://docs.microsoft.com/en-us/powershell/module/az.keyvault/set-azkeyvaultkeyrotationpolicy?view=azps-8.1.0:https://docs.microsoft.com/en-us/azure/data-explorer/kusto/query/scalar-data-types/timespan:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-6-use-a-secure-key-management-process:https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-57pt1r5.pdf", + "DefaultValue": "By default, automatic key rotation is not enabled." + } + ] + }, + { + "Id": "8.3.10", + "Description": "Ensure that Azure Key Vault Managed HSM is Used when Required", + "Checks": [], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that Azure Key Vault Managed HSM is Used when Required", + "RationaleStatement": "Managed HSM is a fully managed, highly available, single-tenant service that ensures FIPS 140-2 Level 3 compliance. It provides centralized key management, isolated access control, and private endpoints for secure access. Integrated with Azure services, it supports migration from Key Vault, ensures data residency, and offers monitoring and auditing for enhanced security.", + "ImpactStatement": "Managed HSM incurs a cost of $0.40 to $5 per month for each actively used HSM-protected key, depending on the key type and quantity. Each key version is billed separately. Additionally, there is an hourly usage fee of $3.20 per Managed HSM pool.", + "RemediationProcedure": "**Remediate from Azure CLI** Run the following command to set `oid` to be the `OID` of the signed-in user: ``` $oid = az ad signed-in-user show --query id -o tsv ``` Alternatively, prepare a space-separated list of OIDs to be provided as the `administrators` of the HSM. Run the following command to create a Managed HSM: ``` az keyvault create --resource-group --hsm-name --retention-days --administrators $oid ``` The command can take several minutes to complete. After the HSM has been created, it must be activated before it can be used. Activation requires providing a minimum of three and a maximum of ten RSA key pairs, as well as the minimum number of keys required to decrypt the security domain (called a quorum). OpenSSL can be used to generate the self-signed certificates, for example: ``` openssl req -newkey rsa:2048 -nodes -keyout cert_1.key -x509 -days 365 -out cert_1.cer ``` Run the following command to download the security domain and activate the Managed HSM: ``` az keyvault security-domain download --hsm-name --sd-wrapping-keys --sd-quorum --security-domain-file .json ``` Store the security domain file and the RSA key pairs securely. They will be required for disaster recovery or for creating another Managed HSM that shares the same security domain so that the two can share keys. The Managed HSM will now be in an active state and ready for use.", + "AuditProcedure": "**Audit from Azure CLI** Run the following command to list key vaults: ``` az keyvault list --query [*].[name,type] ``` Ensure that at least one key vault with type `Microsoft.KeyVault/managedHSMs` exists.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/security/fundamentals/key-management-choose:https://learn.microsoft.com/en-us/azure/key-vault/managed-hsm/overview:https://azure.microsoft.com/en-gb/pricing/details/key-vault/:https://learn.microsoft.com/en-us/azure/key-vault/managed-hsm/quick-create-cli:https://learn.microsoft.com/en-us/cli/azure/keyvault", + "DefaultValue": "" + } + ] + }, + { + "Id": "8.3.11", + "Description": "Ensure Certificate 'Validity Period (in months)' is Less Than or Equal to '12'", + "Checks": [], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure Certificate 'Validity Period (in months)' is Less Than or Equal to '12'", + "RationaleStatement": "Limiting certificate validity reduces the risk of misuse if compromised and helps ensure timely renewal, improving security and reliability.", + "ImpactStatement": "Minor administrative effort required to ensure certificate renewal and lifecycle management.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Key vaults`. 2. Click the name of a key vault. 3. Under `Objects`, click `Certificates`. 4. Click the name of a certificate. 5. Click `Issuance Policy`. 6. Set `Validity Period (in months)` to an integer between 1 and 12, inclusive. 7. Click `Save`. 8. Repeat steps 1-7 for each key vault and certificate requiring remediation. **Remediate from PowerShell** For each certificate requiring remediation, run the following command to set ValidityInMonths to an integer between 1 and 12, inclusive: ``` Set-AzKeyVaultCertificatePolicy -VaultName $vault.VaultName -Name -ValidityInMonths ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Key vaults`. 2. Click the name of a key vault. 3. Under `Objects`, click `Certificates`. 4. Click the name of a certificate. 5. Click `Issuance Policy`. 6. Ensure that `Validity Period (in months)` is set to 12 or less. 7. Repeat steps 1-6 for each key vault and certificate. **Audit from Azure CLI** Run the following command to list key vaults: ``` az keyvault list ``` For each key vault, run the following command to list certificates: ``` az keyvault certificate list --vault-name ``` For each certificate, run the following command to get the certificate policy's validityInMonths setting: ``` az keyvault certificate show --id --query policy.x509CertificateProperties.validityInMonths ``` Ensure that 12 or less is returned. **Audit from PowerShell** Run the following command to list key vaults: ``` Get-AzKeyVault ``` Run the following command to get the key vault with a given name: ``` $vault = Get-AzKeyVault -Name ``` Run the following command to list certificates in the key vault: ``` Get-AzKeyVaultCertificate -VaultName $vault.VaultName ``` Run the following command to get the policy of a certificate with a given name: ``` $certificate = Get-AzKeyVaultCertificatePolicy -VaultName $vault.VaultName -Name ``` Run the following command to get the certificate policy's ValidityInMonths setting: ``` $certificate.ValidityInMonths ``` Ensure that 12 or less is returned. Repeat for each key vault and certificate. **Audit from Azure Policy** - **Policy ID:** 0a075868-4c26-42ef-914c-5bc007359560 - **Name:** 'Certificates should have the specified maximum validity period'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/key-vault/certificates/about-certificates:https://learn.microsoft.com/en-us/cli/azure/keyvault:https://learn.microsoft.com/en-us/powershell/module/az.keyvault", + "DefaultValue": "Validity Period (in months) is set to 12 by default." + } + ] + }, + { + "Id": "8.4.1", + "Description": "Ensure an Azure Bastion Host Exists", + "Checks": [ + "network_bastion_host_exists" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.4 Azure Bastion", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure an Azure Bastion Host Exists", + "RationaleStatement": "The Azure Bastion service allows organizations a more secure means of accessing Azure Virtual Machines over the Internet without assigning public IP addresses to those Virtual Machines. The Azure Bastion service provides Remote Desktop Protocol (RDP) and Secure Shell (SSH) access to Virtual Machines using TLS within a web browser, thus preventing organizations from opening up 3389/TCP and 22/TCP to the Internet on Azure Virtual Machines. Additional benefits of the Bastion service includes Multi-Factor Authentication, Conditional Access Policies, and any other hardening measures configured within Azure Active Directory using a central point of access.", + "ImpactStatement": "The Azure Bastion service incurs additional costs and requires a specific virtual network configuration. The `Standard` tier offers additional configuration options compared to the `Basic` tier and may incur additional costs for those added features.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Click on `Bastions` 2. Select the `Subscription` 3. Select the `Resource group` 4. Type a `Name` for the new Bastion host 5. Select a `Region` 6. Choose `Standard` next to `Tier` 7. Use the slider to set the `Instance count` 8. Select the `Virtual network` or `Create new` 9. Select the `Subnet` named `AzureBastionSubnet`. Create a `Subnet` named `AzureBastionSubnet` using a `/26` CIDR range if it doesn't already exist. 10. Select the appropriate `Public IP address` option. 11. If `Create new` is selected for the `Public IP address` option, provide a `Public IP address name`. 12. If `Use existing` is selected for `Public IP address` option, select an IP address from `Choose public IP address` 13. Click `Next: Tags >` 14. Configure the appropriate `Tags` 15. Click `Next: Advanced >` 16. Select the appropriate `Advanced` options 17. Click `Next: Review + create >` 18. Click `Create` **Remediate from Azure CLI** ``` az network bastion create --location --name --public-ip-address --resource-group --vnet-name --scale-units --sku Standard [--disable-copy-paste true|false] [--enable-ip-connect true|false] [--enable-tunneling true|false] ``` **Remediate from PowerShell** Create the appropriate `Virtual network` settings and `Public IP Address` settings. ``` $subnetName = AzureBastionSubnet $subnet = New-AzVirtualNetworkSubnetConfig -Name $subnetName -AddressPrefix $virtualNet = New-AzVirtualNetwork -Name -ResourceGroupName -Location -AddressPrefix -Subnet $subnet $publicip = New-AzPublicIpAddress -ResourceGroupName -Name -Location -AllocationMethod Dynamic -Sku Standard ``` Create the `Azure Bastion` service using the information within the created variables from above. ``` New-AzBastion -ResourceGroupName -Name -PublicIpAddress $publicip -VirtualNetwork $virtualNet -Sku Standard -ScaleUnit ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Click on `Bastions` 2. Ensure there is at least one `Bastion` host listed under the `Name` column **Audit from Azure CLI** **Note:** The Azure CLI `network bastion` module is in `Preview` as of this writing ``` az network bastion list --subscription ``` Ensure the output of the above command is not empty. **Audit from PowerShell** Retrieve the `Bastion` host(s) information for a specific `Resource Group` ``` Get-AzBastion -ResourceGroupName ``` Ensure the output of the above command is not empty.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/bastion/bastion-overview#sku:https://learn.microsoft.com/en-us/powershell/module/az.network/get-azbastion?view=azps-9.2.0:https://learn.microsoft.com/en-us/cli/azure/network/bastion?view=azure-cli-latest", + "DefaultValue": "By default, the Azure Bastion service is not configured." + } + ] + }, + { + "Id": "8.5", + "Description": "Ensure Azure DDoS Network Protection is Enabled on Virtual Networks", + "Checks": [ + "network_vnet_ddos_protection_enabled" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Azure DDoS Network Protection is Enabled on Virtual Networks", + "RationaleStatement": "Virtual networks and resources are protected against attacks, helping to ensure reliability and availability for critical workloads.", + "ImpactStatement": "Azure DDoS Network Protection incurs a significant fixed monthly charge, with additional charges if more than 100 public IP resources are protected. Careful consideration and analysis should be applied before enabling DDoS protection. Refer to https://azure.microsoft.com/en-us/pricing/details/ddos-protection for detailed pricing information.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Virtual networks`. 2. Click the name of a virtual network. 3. Under `Settings`, click `DDoS protection`. 4. Next to `DDoS Network Protection`, click `Enable`. 5. Provide a DDoS protection plan resource ID, or select a DDoS protection plan from the drop-down menu. 6. Click `Save`. 7. Repeat steps 1-6 for each virtual network requiring remediation. **Remediate from Azure CLI** For each virtual network requiring remediation, run the following command to enable DDoS protection: ``` az network vnet update --resource-group --name --ddos-protection true --ddos-protection-plan ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Virtual networks`. 2. Click the name of a virtual network. 3. Under `Settings`, click `DDoS protection`. 4. Ensure `DDoS Network Protection` is set to `Enable`. 5. Repeat steps 1-4 for each virtual network. **Audit from Azure CLI** Run the following command to list virtual networks: ``` az network vnet list ``` For each virtual network, run the following command to get the DDoS protection setting: ``` az network vnet show --resource-group --name --query enableDdosProtection ``` Ensure `true` is returned. **Audit from Azure Policy** - **Policy ID:** a7aca53f-2ed4-4466-a25e-0b45ade68efd - **Name:** 'Azure DDoS Protection should be enabled'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/ddos-protection/ddos-protection-overview:https://learn.microsoft.com/en-us/azure/ddos-protection/manage-ddos-protection:https://azure.microsoft.com/en-us/pricing/details/ddos-protection:https://learn.microsoft.com/en-us/cli/azure/network/vnet", + "DefaultValue": "DDoS protection is disabled by default." + } + ] + }, + { + "Id": "9.1.1", + "Description": "Ensure Soft Delete for Azure File Shares is Enabled", + "Checks": [ + "storage_ensure_file_shares_soft_delete_is_enabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.1 Azure Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure Soft Delete for Azure File Shares is Enabled", + "RationaleStatement": "Important data could be accidentally deleted or removed by a malicious actor. With soft delete enabled, the data is retained for the defined retention period before permanent deletion, allowing for recovery of the data.", + "ImpactStatement": "When a file share is soft-deleted, the used portion of the storage is charged for the indicated soft-deleted period. All other meters are not charged unless the share is restored.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account with file shares, under `Data storage`, click `File shares`. 1. Under `File share settings`, click the value next to `Soft delete`. 1. Under `Soft delete for all file shares`, click the toggle to set it to `Enabled`. 1. Under `Retention policies`, set an appropriate number of days to retain soft deleted data between 1 and 365, inclusive. 1. Click `Save`. **Remediate from Azure CLI** For each storage account requiring remediation, run the following command to enable soft delete for file shares and set an appropriate number of days for deleted data to be retained, between 1 and 365, inclusive: ``` az storage account file-service-properties update --account-name --enable-delete-retention true --delete-retention-days ``` **Remediate from PowerShell** For each storage account requiring remediation, run the following command to enable soft delete for file shares and set an appropriate number of days for deleted data to be retained, between 1 and 365, inclusive: ``` Update-AzStorageFileServiceProperty -ResourceGroupName -AccountName -EnableShareDeleteRetentionPolicy $true -ShareRetentionDays ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account with file shares, under `Data storage`, click on `File shares`. 1. Under `File share settings`, ensure the value for `Soft delete` shows a number of days between 1 and 365, inclusive. **Audit from Azure CLI** Run the following command to list storage accounts: ``` az storage account list ``` Run the following command to determine if a storage account has file shares: ``` az storage share list --account-name ``` For each storage account with file shares, run the following command: ``` az storage account file-service-properties show --resource-group --account-name ``` Ensure that under `shareDeleteRetentionPolicy`, `enabled` is set to `true`, and `days` is set to an appropriate value between 1 and 365, inclusive. **Audit from PowerShell** Run the following command to list storage accounts: ``` Get-AzStorageAccount -ResourceGroupName ``` With a storage account context set, run the following command to determine if a storage account has file shares: ``` Get-AzStorageShare ``` For each storage account with file shares, run the following command: ``` Get-AzStorageFileServiceProperty -ResourceGroupName -AccountName ``` Ensure that `ShareDeleteRetentionPolicy.Enabled` is set to `True` and `ShareDeleteRetentionPolicy.Days` is set to an appropriate value between 1 and 365, inclusive.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/storage/files/storage-files-enable-soft-delete:https://learn.microsoft.com/en-us/cli/azure/storage/account/file-service-properties:https://learn.microsoft.com/en-us/powershell/module/az.storage/get-azstoragefileserviceproperty:https://learn.microsoft.com/en-us/powershell/module/az.storage/update-azstoragefileserviceproperty:https://learn.microsoft.com/en-us/azure/storage/files/storage-files-prevent-file-share-deletion", + "DefaultValue": "Soft delete is enabled by default at the storage account file share setting level." + } + ] + }, + { + "Id": "9.1.2", + "Description": "Ensure 'SMB protocol version' is Set to 'SMB 3.1.1' or Higher for SMB file shares", + "Checks": [ + "storage_smb_protocol_version_is_latest" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.1 Azure Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure 'SMB protocol version' is Set to 'SMB 3.1.1' or Higher for SMB file shares", + "RationaleStatement": "Using the latest supported SMB protocol version enhances the security of SMB file shares by preventing the exploitation of known vulnerabilities in outdated SMB versions.", + "ImpactStatement": "Using the latest SMB protocol version may impact client compatibility.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage accounts`. 1. Click the name of a storage account. 1. Under `Data storage`, click `File shares`. 1. Under `File share settings`, click the link next to `Security`. 1. If `Profile` is set to `Maximum compatibility`, click the drop-down menu and select `Maximum security` or `Custom`. 1. If selecting `Custom`, under `SMB protocol versions`, uncheck the boxes next to `SMB 2.1` and `SMB 3.0`. 1. Click `Save`. 1. Repeat steps 1-7 for each storage account requiring remediation. **Remediate from Azure CLI** For each storage account requiring remediation, run the following command to set the SMB protocol version: ``` az storage account file-service-properties update --resource-group --account-name --versions SMB3.1.1 ``` **Remediate from PowerShell** For each storage account requiring remediation, run the following command to set the SMB protocol version: ``` Update-AzStorageFileServiceProperty -ResourceGroupName -StorageAccountName -SmbProtocolVersion SMB3.1.1 ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage accounts`. 1. Click the name of a storage account. 1. Under `Data storage`, click `File shares`. 1. Under `File share settings`, click the link next to `Security`. 1. Under `SMB protocol versions`, ensure that `SMB3.1.1` is the only checked protocol version. 1. Repeat steps 1-5 for each storage account. **Audit from Azure CLI** Run the following command to list storage accounts: ``` az storage account list ``` For each storage account, run the following command: ``` az storage account file-service-properties show --resource-group --account-name ``` Ensure that under `protocolSettings` > `smb`, `versions` is set to `SMB3.1.1;` only. **Audit from PowerShell** Run the following command to list storage accounts: ``` Get-AzStorageAccount ``` Run the following command to get the file service properties for a storage account in a resource group with a given name: ``` $storageaccountfileservice = Get-AzStorageFileServiceProperty -ResourceGroupName -AccountName ``` Run the following command to get the SMB protocol version setting: ``` $storageaccountfileservice.ProtocolSettings.Smb.Versions ``` Ensure that the command returns `SMB3.1.1` only. Repeat for each storage account.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-files#recommendations-for-smb-file-shares:https://learn.microsoft.com/en-us/azure/storage/files/files-smb-protocol#smb-security-settings:https://learn.microsoft.com/en-us/cli/azure/storage/account/file-service-properties:https://learn.microsoft.com/en-us/powershell/module/az.storage/get-azstoragefileserviceproperty:https://learn.microsoft.com/en-us/powershell/module/az.storage/update-azstoragefileserviceproperty", + "DefaultValue": "By default, all SMB versions are allowed." + } + ] + }, + { + "Id": "9.1.3", + "Description": "Ensure 'SMB channel encryption' is Set to 'AES-256-GCM' or Higher for SMB file shares", + "Checks": [ + "storage_smb_channel_encryption_with_secure_algorithm" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.1 Azure Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure 'SMB channel encryption' is Set to 'AES-256-GCM' or Higher for SMB file shares", + "RationaleStatement": "AES-256-GCM encryption enhances the security of data transmitted over SMB channels by safeguarding it from unauthorized interception and tampering.", + "ImpactStatement": "Using the AES-256-GCM SMB channel encryption may impact client compatibility.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage accounts`. 1. Click the name of a storage account. 1. Under `Data storage`, click `File shares`. 1. Under `File share settings`, click the link next to `Security`. 1. If `Profile` is set to `Maximum compatibility`, click the drop-down menu and select `Maximum security` or `Custom`. 1. If selecting `Custom`, under `SMB channel encryption`, uncheck the boxes next to `AES-128-CCM` and `AES-128-GCM`. 1. Click `Save`. 1. Repeat steps 1-7 for each storage account requiring remediation. **Remediate from Azure CLI** For each storage account requiring remediation, run the following command to set the SMB channel encryption: ``` az storage account file-service-properties update --resource-group --account-name --channel-encryption AES-256-GCM ``` **Remediate from PowerShell** For each storage account requiring remediation, run the following command to set the SMB channel encryption: ``` Update-AzStorageFileServiceProperty -ResourceGroupName -StorageAccountName -SmbChannelEncryption AES-256-GCM ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage accounts`. 1. Click the name of a storage account. 1. Under `Data storage`, click `File shares`. 1. Under `File share settings`, click the link next to `Security`. 1. Under `SMB channel encryption`, ensure that `AES-256-GCM`, or higher, is the only checked SMB channel encryption setting. 1. Repeat steps 1-5 for each storage account. **Audit from Azure CLI** Run the following command to list storage accounts: ``` az storage account list ``` For each storage account, run the following command: ``` az storage account file-service-properties show --resource-group --account-name ``` Ensure that under `protocolSettings` > `smb`, `channelEncryption` is set to `AES-256-GCM;`, or higher, only. **Audit from PowerShell** Run the following command to list storage accounts: ``` Get-AzStorageAccount ``` Run the following command to get the file service properties for a storage account in a resource group with a given name: ``` $storageaccountfileservice = Get-AzStorageFileServiceProperty -ResourceGroupName -AccountName ``` Run the following command to get the SMB channel encryption setting: ``` $storageaccountfileservice.ProtocolSettings.Smb.ChannelEncryption ``` Ensure that the command returns `AES-256-GCM`, or higher, only. Repeat for each storage account.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-files#recommendations-for-smb-file-shares:https://learn.microsoft.com/en-us/azure/storage/files/files-smb-protocol?tabs=azure-portal#smb-security-settings:https://learn.microsoft.com/en-us/cli/azure/storage/account/file-service-properties:https://learn.microsoft.com/en-us/powershell/module/az.storage/get-azstoragefileserviceproperty:https://learn.microsoft.com/en-us/powershell/module/az.storage/update-azstoragefileserviceproperty", + "DefaultValue": "By default, the following SMB channel encryption algorithms are allowed: - AES-128-CCM - AES-128-GCM - AES-256-GCM" + } + ], + "ConfigRequirements": [ + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ] + } + ] + }, + { + "Id": "9.2.1", + "Description": "Ensure That Soft Delete for Blobs on Azure Blob Storage Storage Accounts is Enabled", + "Checks": [ + "storage_ensure_soft_delete_is_enabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.2 Azure Blob Storage", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure That Soft Delete for Blobs on Azure Blob Storage Storage Accounts is Enabled", + "RationaleStatement": "Blobs can be deleted incorrectly. An attacker or malicious user may do this deliberately in order to cause disruption. Deleting an Azure storage blob results in immediate data loss. Enabling this configuration for Azure storage accounts ensures that even if blobs are deleted from the storage account, the blobs are recoverable for a specific period of time, which is defined in the Retention policies, ranging from 7 to 365 days.", + "ImpactStatement": "All soft-deleted data is billed at the same rate as active data. Additional costs may be incurred for deleted blobs until the soft delete period ends and the data is permanently removed.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 1. For each Storage Account with blob storage, under `Data management`, go to `Data protection`. 1. Check the box next to `Enable soft delete for blobs`. 1. Set the retention period to a sufficient length for your organization. 1. Click `Save`. **Remediate from Azure CLI** For each storage account requiring remediation, run the following command to enable soft delete for blobs: ``` az storage blob service-properties delete-policy update --days-retained --account-name --enable true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 1. For each Storage Account with blob storage, under `Data management`, go to `Data protection`. 1. Ensure that `Enable soft delete for blobs` is checked. 1. Ensure that the retention period is a sufficient length for your organization. **Audit from Azure CLI** Run the following command to list storage accounts: ``` az storage account list ``` Run the following command to determine if a storage account has containers: ``` az storage container list --account-name ``` For each storage account with containers, ensure that the output of the below command contains `enabled: true` and `days` is not `null`: ``` az storage blob service-properties delete-policy show --account-name ```", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/storage/blobs/soft-delete-blob-overview", + "DefaultValue": "Soft delete for blob storage is **enabled** by default on storage accounts created via the Azure Portal, and **disabled** by default on storage accounts created via Azure CLI or PowerShell." + } + ] + }, + { + "Id": "9.2.2", + "Description": "Ensure that Soft Delete for Containers on Azure Blob Storage Storage Accounts is Enabled", + "Checks": [ + "storage_ensure_soft_delete_is_enabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.2 Azure Blob Storage", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Soft Delete for Containers on Azure Blob Storage Storage Accounts is Enabled", + "RationaleStatement": "Blobs can be deleted incorrectly. An attacker or malicious user may do this deliberately in order to cause disruption. Deleting an Azure storage blob results in immediate data loss. Enabling this configuration for Azure storage accounts ensures that even if blobs are deleted from the storage account, the blobs are recoverable for a specific period of time, which is defined in the Retention policies, ranging from 7 to 365 days.", + "ImpactStatement": "All soft-deleted data is billed at the same rate as active data. Additional costs may be incurred for deleted blobs until the soft delete period ends and the data is permanently removed.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 1. For each Storage Account with blob storage, under `Data management`, go to `Data protection`. 1. Check the box next to `Enable soft delete for blobs`. 1. Set the retention period to a sufficient length for your organization. 1. Click `Save`. **Remediate from Azure CLI** For each storage account requiring remediation, run the following command to enable soft delete for blobs: ``` az storage blob service-properties delete-policy update --days-retained --account-name --enable true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 1. For each Storage Account with blob storage, under `Data management`, go to `Data protection`. 1. Ensure that `Enable soft delete for blobs` is checked. 1. Ensure that the retention period is a sufficient length for your organization. **Audit from Azure CLI** Run the following command to list storage accounts: ``` az storage account list ``` Run the following command to determine if a storage account has containers: ``` az storage container list --account-name ``` For each storage account with containers, ensure that the output of the below command contains `enabled: true` and `days` is not `null`: ``` az storage blob service-properties delete-policy show --account-name ```", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/storage/blobs/soft-delete-blob-overview", + "DefaultValue": "Soft delete for blob storage is **enabled** by default on storage accounts created via the Azure Portal, and **disabled** by default on storage accounts created via Azure CLI or PowerShell." + } + ] + }, + { + "Id": "9.2.3", + "Description": "Ensure 'Versioning' is Set to 'Enabled' on Azure Blob Storage Storage Accounts", + "Checks": [ + "storage_blob_versioning_is_enabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.2 Azure Blob Storage", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure 'Versioning' is Set to 'Enabled' on Azure Blob Storage Storage Accounts", + "RationaleStatement": "Blob versioning safeguards data integrity and enables recovery by retaining previous versions of stored objects, facilitating quick restoration from accidental deletion, modification, or malicious activity.", + "ImpactStatement": "Enabling blob versioning for a storage account creates a new version with each write operation to a blob, which can increase storage costs. To control these costs, a lifecycle management policy can be applied to automatically delete older versions.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage accounts`. 1. Click the name of a storage account with blob storage. 1. In the `Overview` page, on the `Properties` tab, under `Blob service`, click `Disabled` next to `Versioning`. 1. Under `Tracking`, check the box next to `Enable versioning for blobs`. 1. Select the radio button next to `Keep all versions` or `Delete versions after (in days)`. 1. If selecting to delete versions, enter a number of in the box after which to delete blob versions. 1. Click `Save`. 1. Repeat steps 1-7 for each storage account with blob storage. **Remediate from Azure CLI** For each storage account requiring remediation, run the following command to enable blob versioning: ``` az storage account blob-service-properties update --account-name --enable-versioning true ``` **Remediate from PowerShell** For each storage account requiring remediation, run the following command to enable blob versioning: ``` Update-AzStorageBlobServiceProperty -ResourceGroupName -StorageAccountName -IsVersioningEnabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage accounts`. 1. Click the name of a storage account with blob storage. 1. In the `Overview` page, on the `Properties` tab, under `Blob service`, ensure `Versioning` is set to `Enabled`. 1. Repeat steps 1-3 for each storage account with blob storage. **Audit from Azure CLI** Run the following command to list storage accounts: ``` az storage account list ``` Run the following command to determine if a storage account has containers: ``` az storage container list --account-name ``` For each storage account with containers, ensure that the output of the below command contains `isVersioningEnabled: true`: ``` az storage account blob-service-properties show --account-name ``` **Audit from PowerShell** Run the following command to list storage accounts: ``` Get-AzStorageAccount ``` Run the following command to create an Azure Storage context for a storage account: ``` $context = New-AzStorageContext -StorageAccountName ``` Run the following command to list containers for the storage account: ``` Get-AzStorageContainer -Context $context ``` If the storage account has containers, run the following command to get the blob service properties of the storage account: ``` $account = Get-AzStorageBlobServiceProperty -ResourceGroupName -AccountName ``` Run the following command to get the blob versioning setting for the storage account: ``` $account.IsVersioningEnabled ``` Ensure that the command returns `True`. Repeat for each storage account. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [c36a325b-ae04-4863-ad4f-19c6678f8e08](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc36a325b-ae04-4863-ad4f-19c6678f8e08) **- Name:** 'Configure your Storage account to enable blob versioning'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/cli/azure/storage/account:https://learn.microsoft.com/en-us/cli/azure/storage/account/blob-service-properties:https://learn.microsoft.com/en-us/powershell/module/az.storage/get-azstorageaccount:https://learn.microsoft.com/en-us/powershell/module/az.storage/new-azstoragecontext:https://learn.microsoft.com/en-us/powershell/module/az.storage/get-azstoragecontainer:https://learn.microsoft.com/en-us/powershell/module/az.storage/get-azstorageblobserviceproperty:https://learn.microsoft.com/en-us/powershell/module/az.storage/update-azstorageblobserviceproperty:https://learn.microsoft.com/en-us/azure/storage/blobs/versioning-overview:https://learn.microsoft.com/en-us/azure/storage/blobs/lifecycle-management-overview", + "DefaultValue": "Blob versioning is disabled by default on storage accounts." + } + ] + }, + { + "Id": "9.3.1.1", + "Description": "Ensure That 'Enable key rotation reminders' is Enabled for Each Storage Account", + "Checks": [ + "storage_infrastructure_encryption_is_enabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure That 'Enable key rotation reminders' is Enabled for Each Storage Account", + "RationaleStatement": "Reminders such as those generated by this recommendation will help maintain a regular and healthy cadence for activities which improve the overall efficacy of a security program. Cryptographic key rotation periods will vary depending on your organization's security requirements and the type of data which is being stored in the Storage Account. For example, PCI DSS mandates that cryptographic keys be replaced or rotated 'regularly,' and advises that keys for static data stores be rotated every 'few months.' For the purposes of this recommendation, 90 days will be prescribed for the reminder. Review and adjustment of the 90 day period is recommended, and may even be necessary. Your organization's security requirements should dictate the appropriate setting.", + "ImpactStatement": "This recommendation only creates a periodic reminder to regenerate access keys. Regenerating access keys can affect services in Azure as well as the organization's applications that are dependent on the storage account. All clients that use the access key to access the storage account must be updated to use the new key.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts` 1. For each Storage Account that is not compliant, under `Security + networking`, go to `Access keys` 1. Click `Set rotation reminder` 1. Check `Enable key rotation reminders` 1. In the `Send reminders` field select `Custom`, then set the `Remind me every` field to `90` and the period drop down to `Days` 1. Click `Save` **Remediate from Powershell** ``` $rgName = $accountName = $account = Get-AzStorageAccount -ResourceGroupName $rgName -Name $accountName if ($account.KeyCreationTime.Key1 -eq $null -or $account.KeyCreationTime.Key2 -eq $null){ Write-output (You must regenerate both keys at least once before setting expiration policy) } else { $account = Set-AzStorageAccount -ResourceGroupName $rgName -Name $accountName -KeyExpirationPeriodInDay 90 } $account.KeyPolicy.KeyExpirationPeriodInDays ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts` 2. For each Storage Account, under `Security + networking`, go to `Access keys` 3. If the button `Edit rotation reminder` is displayed, the Storage Account is compliant. Click `Edit rotation reminder` and review the `Remind me every` field for a desirable periodic setting that fits your security program's needs. If the button `Set rotation reminder` is displayed, the Storage Account is not compliant. **Audit from Powershell** ``` $rgName = $accountName = $account = Get-AzStorageAccount -ResourceGroupName $rgName -Name $accountName Write-Output $accountName -> Write-Output Expiration Reminder set to: $($account.KeyPolicy.KeyExpirationPeriodInDays) Days Write-Output Key1 Last Rotated: $($account.KeyCreationTime.Key1.ToShortDateString()) Write-Output Key2 Last Rotated: $($account.KeyCreationTime.Key2.ToShortDateString()) ``` Key rotation is recommended if the creation date for any key is empty. If the reminder is set, the period in days will be returned. The recommended period is 90 days. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [044985bb-afe1-42cd-8a36-9d5d42424537](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F044985bb-afe1-42cd-8a36-9d5d42424537) **- Name:** 'Storage account keys should not be expired'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/storage/common/storage-create-storage-account#regenerate-storage-access-keys:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-1-separate-and-limit-highly-privilegedadministrative-users:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-3-manage-application-identities-securely-and-automatically:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-6-define-and-implement-identity-and-privileged-access-strategy:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-8-restrict-the-exposure-of-credentials-and-secrets:https://www.pcidssguide.com/pci-dss-key-rotation-requirements/:https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-57pt1r5.pdf", + "DefaultValue": "By default, Key rotation reminders are not configured." + } + ] + }, + { + "Id": "9.3.1.2", + "Description": "Ensure That Storage Account Access keys are Periodically Regenerated", + "Checks": [ + "storage_key_rotation_90_days" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure That Storage Account Access keys are Periodically Regenerated", + "RationaleStatement": "When a storage account is created, Azure generates two 512-bit storage access keys which are used for authentication when the storage account is accessed. Rotating these keys periodically ensures that any inadvertent access or exposure does not result from the compromise of these keys. Cryptographic key rotation periods will vary depending on your organization's security requirements and the type of data which is being stored in the Storage Account. For example, PCI DSS mandates that cryptographic keys be replaced or rotated 'regularly,' and advises that keys for static data stores be rotated every 'few months.' For the purposes of this recommendation, 90 days will be prescribed for the reminder. Review and adjustment of the 90 day period is recommended, and may even be necessary. Your organization's security requirements should dictate the appropriate setting.", + "ImpactStatement": "Regenerating access keys can affect services in Azure as well as the organization's applications that are dependent on the storage account. All clients who use the access key to access the storage account must be updated to use the new key.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 2. For each Storage Account with outdated keys, under `Security + networking`, go to `Access keys`. 3. Click `Rotate key` next to the outdated key, then click `Yes` to the prompt confirming that you want to regenerate the access key. After Azure regenerates the Access Key, you can confirm that `Access keys` reflects a `Last rotated` date of `(0 days ago)`.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 2. For each Storage Account, under `Security + networking`, go to `Access keys`. 3. Review the date and days in the `Last rotated` field for **each** key. If the `Last rotated` field indicates a number or days greater than 90 [or greater than your organization's period of validity], the key should be rotated. **Audit from Azure CLI** 1. Get a list of storage accounts ``` az storage account list --subscription ``` Make a note of `id`, `name` and `resourceGroup`. 2. For every storage account make sure that key is regenerated in the past 90 days. ``` az monitor activity-log list --namespace Microsoft.Storage --offset 90d --query [?contains(authorization.action, 'regenerateKey')] --resource-id ``` The output should contain ``` authorization/scope: AND authorization/action: Microsoft.Storage/storageAccounts/regeneratekey/action AND status/localizedValue: Succeeded status/Value: Succeeded ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [044985bb-afe1-42cd-8a36-9d5d42424537](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F044985bb-afe1-42cd-8a36-9d5d42424537) **- Name:** 'Storage account keys should not be expired'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/storage/common/storage-create-storage-account#regenerate-storage-access-keys:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-1-separate-and-limit-highly-privilegedadministrative-users:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-2-protect-identity-and-authentication-systems:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-6-define-and-implement-identity-and-privileged-access-strategy:https://www.pcidssguide.com/pci-dss-key-rotation-requirements/:https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-57pt1r5.pdf", + "DefaultValue": "By default, access keys are not regenerated periodically." + } + ] + }, + { + "Id": "9.3.1.3", + "Description": "Ensure 'Allow storage account key access' for Azure Storage Accounts is 'Disabled'", + "Checks": [ + "storage_account_key_access_disabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure 'Allow storage account key access' for Azure Storage Accounts is 'Disabled'", + "RationaleStatement": "Microsoft Entra ID provides superior security and ease of use compared to Shared Key and is recommended by Microsoft. To require clients to use Microsoft Entra ID for authorizing requests, you can disallow requests to the storage account that are authorized with Shared Key.", + "ImpactStatement": "When you disallow Shared Key authorization for a storage account, any requests to the account that are authorized with Shared Key, including shared access signatures (SAS), will be denied. Client applications that currently access the storage account using the Shared Key will no longer function.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage accounts`. 1. Click on a storage account. 1. Under `Settings`, click `Configuration`. 1. Under `Allow storage account key access`, click the radio button next to `Disabled`. 1. Click `Save`. 1. Repeat steps 1-5 for each storage account requiring remediation. **Remediate from Azure CLI** For each storage account requiring remediation, run the following command to disallow shared key authorization: ``` az storage account update --resource-group --name --allow-shared-key-access false ``` **Remediate from PowerShell** For each storage account requiring remediation, run the following command to disallow shared key authorization: ``` Set-AzStorageAccount -ResourceGroupName -Name -AllowSharedKeyAccess $false ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage accounts`. 1. Click on a storage account. 1. Under `Settings`, click `Configuration`. 1. Under `Allow storage account key access`, ensure that the radio button next to `Disabled` is selected. 1. Repeat steps 1-4 for each storage account. **Audit from Azure CLI** Run the following command to list storage accounts: ``` az storage account list ``` For each storage account, run the following command: ``` az storage account show --resource-group --name ``` Ensure that `allowSharedKeyAccess` is set to `false`. **Audit from PowerShell** Run the following command to list storage accounts: ``` Get-AzStorageAccount ``` Run the following command to get the storage account in a resource group with a given name: ``` $storageAccount = Get-AzStorageAccount -ResourceGroupName -Name ``` Run the following command to get the shared key access setting for the storage account: ``` $storageAccount.allowSharedKeyAccess ``` Ensure that the command returns `False`. Repeat for each storage account. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [8c6a50c6-9ffd-4ae7-986f-5fa6111f9a54](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F8c6a50c6-9ffd-4ae7-986f-5fa6111f9a54) **- Name:** 'Storage accounts should prevent shared key access'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/storage/common/shared-key-authorization-prevent:https://learn.microsoft.com/en-us/cli/azure/storage/account:https://learn.microsoft.com/en-us/powershell/module/az.storage/get-azstorageaccount:https://learn.microsoft.com/en-us/powershell/module/az.storage/set-azstorageaccount", + "DefaultValue": "The AllowSharedKeyAccess property of a storage account is not set by default and does not return a value until you explicitly set it. The storage account permits requests that are authorized with the Shared Key when the property value is **null** or when it is **true**." + } + ] + }, + { + "Id": "9.3.2.1", + "Description": "Ensure Private Endpoints are Used to Access Storage Accounts", + "Checks": [ + "storage_ensure_private_endpoints_in_storage_accounts" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Private Endpoints are Used to Access Storage Accounts", + "RationaleStatement": "Securing traffic between services through encryption protects the data from easy interception and reading.", + "ImpactStatement": "If an Azure Virtual Network is not implemented correctly, this may result in the loss of critical network traffic. Private endpoints are charged per hour of use. Refer to https://azure.microsoft.com/en-us/pricing/details/private-link/ and https://azure.microsoft.com/en-us/pricing/calculator/ to estimate potential costs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Open the `Storage Accounts` blade 1. For each listed Storage Account, perform the following: 1. Under the `Security + networking` heading, click on `Networking` 1. Click on the `Private endpoint connections` tab at the top of the networking window 1. Click the `+ Private endpoint` button 1. In the `1 - Basics` tab/step: - `Enter a name` that will be easily recognizable as associated with the Storage Account (*Note*: The Network Interface Name will be automatically completed, but you can customize it if needed.) - Ensure that the `Region` matches the region of the Storage Account - Click `Next` 1. In the `2 - Resource` tab/step: - Select the `target sub-resource` based on what type of storage resource is being made available - Click `Next` 1. In the `3 - Virtual Network` tab/step: - Select the `Virtual network` that your Storage Account will be connecting to - Select the `Subnet` that your Storage Account will be connecting to - (Optional) Select other network settings as appropriate for your environment - Click `Next` 1. In the `4 - DNS` tab/step: - (Optional) Select other DNS settings as appropriate for your environment - Click `Next` 1. In the `5 - Tags` tab/step: - (Optional) Set any tags that are relevant to your organization - Click `Next` 1. In the `6 - Review + create` tab/step: - A validation attempt will be made and after a few moments it should indicate `Validation Passed` - if it does not pass, double-check your settings before beginning more in depth troubleshooting. - If validation has passed, click `Create` then wait for a few minutes for the scripted deployment to complete. Repeat the above procedure for each Private Endpoint required within every Storage Account. **Remediate from PowerShell** ``` $storageAccount = Get-AzStorageAccount -ResourceGroupName '' -Name '' $privateEndpointConnection = @{ Name = 'connectionName' PrivateLinkServiceId = $storageAccount.Id GroupID = blob|blob_secondary|file|file_secondary|table|table_secondary|queue|queue_secondary|web|web_secondary|dfs|dfs_secondary } $privateLinkServiceConnection = New-AzPrivateLinkServiceConnection @privateEndpointConnection $virtualNetDetails = Get-AzVirtualNetwork -ResourceGroupName '' -Name '' $privateEndpoint = @{ ResourceGroupName = '' Name = '' Location = '' Subnet = $virtualNetDetails.Subnets[0] PrivateLinkServiceConnection = $privateLinkServiceConnection } New-AzPrivateEndpoint @privateEndpoint ``` **Remediate from Azure CLI** ``` az network private-endpoint create --resource-group --name --vnet-name --subnet --private-connection-resource-id --connection-name --group-id ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Open the `Storage Accounts` blade. 1. For each listed Storage Account, perform the following check: 1. Under the `Security + networking` heading, click on `Networking`. 1. Click on the `Private endpoint connections` tab at the top of the networking window. 1. Ensure that for each VNet that the Storage Account must be accessed from, a unique Private Endpoint is deployed and the `Connection state` for each Private Endpoint is `Approved`. Repeat the procedure for each Storage Account. **Audit from PowerShell** ``` $storageAccount = Get-AzStorageAccount -ResourceGroup '' -Name '' Get-AzPrivateEndpoint -ResourceGroup ''|Where-Object {$_.PrivateLinkServiceConnectionsText -match $storageAccount.id} ``` If the results of the second command returns information, the Storage Account is using a Private Endpoint and complies with this Benchmark, otherwise if the results of the second command are empty, the Storage Account generates a finding. **Audit from Azure CLI** ``` az storage account show --name '' --query privateEndpointConnections[0].id ``` If the above command returns data, the Storage Account complies with this Benchmark, otherwise if the results are empty, the Storage Account generates a finding. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [6edd7eda-6dd8-40f7-810d-67160c639cd9](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F6edd7eda-6dd8-40f7-810d-67160c639cd9) **- Name:** 'Storage accounts should use private link'", + "AdditionalInformation": "A NAT gateway is the recommended solution for outbound internet access. This recommendation is based on the Common Reference Recommendation `Ensure Private Endpoints are used to access {service}`, from the `Common Reference Recommendations > Networking > Private Endpoints` section.", + "References": "https://docs.microsoft.com/en-us/azure/storage/common/storage-private-endpoints:https://docs.microsoft.com/en-us/azure/virtual-network/virtual-networks-overview:https://docs.microsoft.com/en-us/azure/private-link/create-private-endpoint-portal:https://docs.microsoft.com/en-us/azure/private-link/create-private-endpoint-cli?tabs=dynamic-ip:https://docs.microsoft.com/en-us/azure/private-link/create-private-endpoint-powershell?tabs=dynamic-ip:https://docs.microsoft.com/en-us/azure/private-link/tutorial-private-endpoint-storage-portal:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-network-security#ns-2-secure-cloud-native-services-with-network-controls", + "DefaultValue": "By default, Private Endpoints are not created for Storage Accounts." + } + ] + }, + { + "Id": "9.3.2.2", + "Description": "Ensure that 'Public Network Access' is 'Disabled' for Storage Accounts", + "Checks": [ + "storage_account_public_network_access_disabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that 'Public Network Access' is 'Disabled' for Storage Accounts", + "RationaleStatement": "The default network configuration for a storage account permits a user with appropriate permissions to configure public network access to containers and blobs in a storage account. Keep in mind that public access to a container is always turned off by default and must be explicitly configured to permit anonymous requests. It grants read-only access to these resources without sharing the account key, and without requiring a shared access signature. It is recommended not to provide public network access to storage accounts until, and unless, it is strongly desired. A shared access signature token or Azure AD RBAC should be used for providing controlled and timed access to blob containers.", + "ImpactStatement": "Access will have to be managed using shared access signatures or via Azure AD RBAC. For classic storage accounts (to be retired on August 31, 2024), each container in the account must be configured to block anonymous access. Either configure all containers or to configure at the storage account level, migrate to the Azure Resource Manager deployment model.", + "RemediationProcedure": "**Remediate from Azure Portal** First, follow Microsoft documentation and create shared access signature tokens for your blob containers. Then, 1. Go to `Storage Accounts`. 1. For each storage account, under the `Security + networking` section, click `Networking`. 1. Set `Public network access` to `Disabled`. 1. Click `Save`. **Remediate from Azure CLI** Set 'Public Network Access' to `Disabled` on the storage account ``` az storage account update --name --resource-group --public-network-access Disabled ``` **Remediate from PowerShell** For each Storage Account, run the following to set the `PublicNetworkAccess` setting to `Disabled` ``` Set-AzStorageAccount -ResourceGroupName -Name -PublicNetworkAccess Disabled ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 2. For each storage account, under the `Security + networking` section, click `Networking`. 3. Ensure the `Public network access` setting is set to `Disabled`. **Audit from Azure CLI** Ensure `publicNetworkAccess` is `Disabled` ``` az storage account show --name --resource-group --query {publicNetworkAccess:publicNetworkAccess} ``` **Audit from PowerShell** For each Storage Account, ensure `PublicNetworkAccess` is `Disabled` ``` Get-AzStorageAccount -Name -ResourceGroupName |select PublicNetworkAccess ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [b2982f36-99f2-4db5-8eff-283140c09693](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb2982f36-99f2-4db5-8eff-283140c09693) **- Name:** 'Storage accounts should disable public network access'", + "AdditionalInformation": "This recommendation is based on the Common Reference Recommendation `Ensure public network access is Disabled`, from the `Common Reference Recommendations > Networking > Virtual Networks (VNets)` section.", + "References": "https://docs.microsoft.com/en-us/azure/storage/blobs/storage-manage-access-to-resources:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-2-define-and-implement-enterprise-segmentationseparation-of-duties-strategy:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-network-security#ns-2-secure-cloud-native-services-with-network-controls:https://docs.microsoft.com/en-us/azure/storage/blobs/assign-azure-role-data-access:https://learn.microsoft.com/en-us/azure/storage/common/storage-network-security?tabs=azure-portal", + "DefaultValue": "By default, `Public Network Access` is set to `Enabled from all networks` for the Storage Account." + } + ] + }, + { + "Id": "9.3.2.3", + "Description": "Ensure Default Network Access Rule for Storage Accounts is Set to Deny", + "Checks": [ + "storage_default_network_access_rule_is_denied" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure Default Network Access Rule for Storage Accounts is Set to Deny", + "RationaleStatement": "Storage accounts should be configured to deny access to traffic from all networks (including internet traffic). Access can be granted to traffic from specific Azure Virtual networks, allowing a secure network boundary for specific applications to be built. Access can also be granted to public internet IP address ranges to enable connections from specific internet or on-premises clients. When network rules are configured, only applications from allowed networks can access a storage account. When calling from an allowed network, applications continue to require proper authorization (a valid access key or SAS token) to access the storage account.", + "ImpactStatement": "All allowed networks will need to be whitelisted on each specific network, creating administrative overhead. This may result in loss of network connectivity, so do not turn on for critical resources during business hours.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Security + networking`, click `Networking`. 1. Click the `Firewalls and virtual networks` heading. 1. Set `Public network access` to `Enabled from selected virtual networks and IP addresses`. 1. Add rules to allow traffic from specific networks and IP addresses. 1. Click `Save`. **Remediate from Azure CLI** Use the below command to update `default-action` to `Deny`. ``` az storage account update --name --resource-group --default-action Deny ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to Storage Accounts. 2. For each storage account, under `Security + networking`, click `Networking`. 4. Click the `Firewalls and virtual networks` heading. 3. Ensure that `Public network access` is not set to `Enabled from all networks`. **Audit from Azure CLI** Ensure `defaultAction` is not set to ` Allow`. ``` az storage account list --query '[*].networkRuleSet' ``` **Audit from PowerShell** ``` Connect-AzAccount Set-AzContext -Subscription Get-AzStorageAccountNetworkRuleset -ResourceGroupName -Name |Select-Object DefaultAction ``` PowerShell Result - Non-Compliant ``` DefaultAction : Allow ``` PowerShell Result - Compliant ``` DefaultAction : Deny ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [34c877ad-507e-4c82-993e-3452a6e0ad3c](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F34c877ad-507e-4c82-993e-3452a6e0ad3c) **- Name:** 'Storage accounts should restrict network access' - **Policy ID:** [2a1a9cdf-e04d-429a-8416-3bfb72a1b26f](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F2a1a9cdf-e04d-429a-8416-3bfb72a1b26f) **- Name:** 'Storage accounts should restrict network access using virtual network rules'", + "AdditionalInformation": "This recommendation is based on the Common Reference Recommendation `Ensure Network Access Rules are set to Deny-by-default`, from the `Common Reference Recommendations > Networking > Virtual Networks (VNets)` section.", + "References": "https://docs.microsoft.com/en-us/azure/storage/common/storage-network-security:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-2-define-and-implement-enterprise-segmentationseparation-of-duties-strategy:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-network-security#ns-2-secure-cloud-native-services-with-network-controls", + "DefaultValue": "By default, Storage Accounts will accept connections from clients on any network." + } + ] + }, + { + "Id": "9.3.3.1", + "Description": "Ensure that 'Default to Microsoft Entra authorization in the Azure portal' is Set to 'Enabled'", + "Checks": [ + "storage_default_to_entra_authorization_enabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that 'Default to Microsoft Entra authorization in the Azure portal' is Set to 'Enabled'", + "RationaleStatement": "Microsoft Entra ID provides superior security and ease of use over Shared Key.", + "ImpactStatement": "Users will need appropriate RBAC permissions to access storage data.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage accounts`. 1. Click the name of a storage account. 1. Under `Settings`, click `Configuration`. 1. Under `Default to Microsoft Entra authorization in the Azure portal`, click the radio button next to `Enabled`. 1. Click `Save`. 1. Repeat steps 1-5 for each storage account requiring remediation. **Remediate from Azure CLI** For each storage account requiring remediation, run the following command to enable `defaultToOAuthAuthentication`: ``` az storage account update --resource-group --name --set defaultToOAuthAuthentication=true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage accounts`. 1. Click the name of a storage account. 1. Under `Settings`, click `Configuration`. 1. Ensure that `Default to Microsoft Entra authorization in the Azure portal` is set to `Enabled`. 1. Repeat steps 1-4 for each storage account. **Audit from Azure CLI** Run the following command to get the `name` and `defaultToOAuthAuthentication` setting for each storage account: ``` az storage account list --query [*].[name,defaultToOAuthAuthentication] ``` Ensure that `true` is returned for each storage account.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/storage/blobs/authorize-data-operations-portal#default-to-microsoft-entra-authorization-in-the-azure-portal:https://learn.microsoft.com/en-us/cli/azure/storage/account?view=azure-cli-latest", + "DefaultValue": "By default, `defaultToOAuthAuthentication` is disabled." + } + ] + }, + { + "Id": "9.3.4", + "Description": "Ensure that 'Secure transfer required' is Set to 'Enabled'", + "Checks": [ + "storage_secure_transfer_required_is_enabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that 'Secure transfer required' is Set to 'Enabled'", + "RationaleStatement": "The secure transfer option enhances the security of a storage account by only allowing requests to the storage account by a secure connection. For example, when calling REST APIs to access storage accounts, the connection must use HTTPS. Any requests using HTTP will be rejected when 'secure transfer required' is enabled. When using the Azure files service, connection without encryption will fail, including scenarios using SMB 2.1, SMB 3.0 without encryption, and some flavors of the Linux SMB client. Because Azure storage doesnt support HTTPS for custom domain names, this option is not applied when using a custom domain name.", + "ImpactStatement": "Applications using HTTP will need to be updated to use HTTPS.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Settings`, click `Configuration`. 1. Set `Secure transfer required` to `Enabled`. 1. Click `Save`. **Remediate from Azure CLI** Use the below command to enable `Secure transfer required` for a `Storage Account` ``` az storage account update --name --resource-group --https-only true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Settings`, click `Configuration`. 1. Ensure that `Secure transfer required` is set to `Enabled`. **Audit from Azure CLI** Use the below command to ensure the `Secure transfer required` is enabled for all the `Storage Accounts` by ensuring the output contains `true` for each of the `Storage Accounts`. ``` az storage account list --query [*].[name,enableHttpsTrafficOnly] ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [404c3081-a854-4457-ae30-26a93ef643f9](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F404c3081-a854-4457-ae30-26a93ef643f9) **- Name:** 'Secure transfer to storage accounts should be enabled'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/storage/blobs/security-recommendations#encryption-in-transit:https://docs.microsoft.com/en-us/cli/azure/storage/account?view=azure-cli-latest#az_storage_account_list:https://docs.microsoft.com/en-us/cli/azure/storage/account?view=azure-cli-latest#az_storage_account_update:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-3-encrypt-sensitive-data-in-transit", + "DefaultValue": "By default, `Secure transfer required` is set to `Disabled`." + } + ] + }, + { + "Id": "9.3.5", + "Description": "Ensure 'Allow trusted Microsoft services to access this resource' is Enabled for Storage Account Access", + "Checks": [ + "storage_ensure_azure_services_are_trusted_to_access_is_enabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure 'Allow trusted Microsoft services to access this resource' is Enabled for Storage Account Access", + "RationaleStatement": "Turning on firewall rules for a storage account will block access to incoming requests for data, including from other Azure services. We can re-enable this functionality by allowing access to `trusted Azure services` through networking exceptions.", + "ImpactStatement": "This creates authentication credentials for services that need access to storage resources so that services will no longer need to communicate via network request. There may be a temporary loss of communication as you set each Storage Account. It is recommended to not do this on mission-critical resources during business hours.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Security + networking`, click `Networking`. 1. Click on the `Firewalls and virtual networks` heading. 1. Under `Exceptions`, check the box next to `Allow Azure services on the trusted services list to access this storage account`. 1. Click `Save`. **Remediate from Azure CLI** Use the below command to update `bypass` to `Azure services`. ``` az storage account update --name --resource-group --bypass AzureServices ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Security + networking`, click `Networking`. 1. Click on the `Firewalls and virtual networks` heading. 1. Under `Exceptions`, ensure that `Allow Azure services on the trusted services list to access this storage account` is checked. **Audit from Azure CLI** Ensure `bypass` contains `AzureServices` ``` az storage account list --query '[*].networkRuleSet' ``` **Audit from PowerShell** ``` Connect-AzAccount Set-AzContext -Subscription Get-AzStorageAccountNetworkRuleset -ResourceGroupName -Name |Select-Object Bypass ``` If the response from the above command is `None`, the storage account configuration is out of compliance with this check. If the response is `AzureServices`, the storage account configuration is in compliance with this check. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [c9d007d0-c057-4772-b18c-01e546713bcd](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc9d007d0-c057-4772-b18c-01e546713bcd) **- Name:** 'Storage accounts should allow access from trusted Microsoft services'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/storage/common/storage-network-security:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-network-security#ns-2-secure-cloud-native-services-with-network-controls", + "DefaultValue": "By default, Storage Accounts will accept connections from clients on any network." + } + ] + }, + { + "Id": "9.3.6", + "Description": "Ensure the 'Minimum TLS version' for Storage Accounts is Set to 'Version 1.2'", + "Checks": [ + "storage_ensure_minimum_tls_version_12" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure the 'Minimum TLS version' for Storage Accounts is Set to 'Version 1.2'", + "RationaleStatement": "TLS 1.0 has known vulnerabilities and has been replaced by later versions of the TLS protocol. Continued use of this legacy protocol affects the security of data in transit.", + "ImpactStatement": "When set to TLS 1.2 all requests must leverage this version of the protocol. Applications leveraging legacy versions of the protocol will fail.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Settings`, click `Configuration`. 1. Set the `Minimum TLS version` to `Version 1.2`. 1. Click `Save`. **Remediate from Azure CLI** ``` az storage account update \\ --name \\ --resource-group \\ --min-tls-version TLS1_2 ``` **Remediate from PowerShell** To set the minimum TLS version, run the following command: ``` Set-AzStorageAccount -AccountName ` -ResourceGroupName ` -MinimumTlsVersion TLS1_2 ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Settings`, click `Configuration`. 1. Ensure that the `Minimum TLS version` is set to `Version 1.2`. **Audit from Azure CLI** Get a list of all storage accounts and their resource groups ``` az storage account list | jq '.[] | {name, resourceGroup}' ``` Then query the minimumTLSVersion field ``` az storage account show \\ --name \\ --resource-group \\ --query minimumTlsVersion \\ --output tsv ``` **Audit from PowerShell** To get the minimum TLS version, run the following command: ``` (Get-AzStorageAccount -Name -ResourceGroupName ).MinimumTlsVersion ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [fe83a0eb-a853-422d-aac2-1bffd182c5d0](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Ffe83a0eb-a853-422d-aac2-1bffd182c5d0) **- Name:** 'Storage accounts should have the specified minimum TLS version'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/storage/common/transport-layer-security-configure-minimum-version?tabs=portal:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-3-encrypt-sensitive-data-in-transit", + "DefaultValue": "If a storage account is created through the portal, the MinimumTlsVersion property for that storage account will be set to TLS 1.2. If a storage account is created through PowerShell or CLI, the MinimumTlsVersion property for that storage account will not be set, and defaults to TLS 1.0." + } + ] + }, + { + "Id": "9.3.7", + "Description": "Ensure 'Cross Tenant Replication' is Not Enabled", + "Checks": [ + "storage_cross_tenant_replication_disabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure 'Cross Tenant Replication' is Not Enabled", + "RationaleStatement": "Disabling Cross Tenant Replication minimizes the risk of unauthorized data access and ensures that data governance policies are strictly adhered to. This control is especially critical for organizations with stringent data security and privacy requirements, as it prevents the accidental sharing of sensitive information.", + "ImpactStatement": "Disabling Cross Tenant Replication may affect data availability and sharing across different Azure tenants. Ensure that this change aligns with your organizational data sharing and availability requirements.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Data management`, click `Object replication`. 1. Click `Advanced settings`. 1. Uncheck `Allow cross-tenant replication`. 1. Click `OK`. **Remediate from Azure CLI** Replace the information within <> with appropriate values: ``` az storage account update --name --resource-group --allow-cross-tenant-replication false ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Data management`, click `Object replication`. 1. Click `Advanced settings`. 1. Ensure `Allow cross-tenant replication` is not checked. **Audit from Azure CLI** ``` az storage account list --query [*].[name,allowCrossTenantReplication] ``` The value of `false` should be returned for each storage account listed. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [92a89a79-6c52-4a7e-a03f-61306fc49312](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F92a89a79-6c52-4a7e-a03f-61306fc49312) **- Name:** 'Storage accounts should prevent cross tenant object replication'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/storage/blobs/object-replication-prevent-cross-tenant-policies?tabs=portal", + "DefaultValue": "For new storage accounts created after Dec 15, 2023 cross tenant replication is not enabled." + } + ] + }, + { + "Id": "9.3.8", + "Description": "Ensure that 'Allow Blob Anonymous Access' is Set to 'Disabled'", + "Checks": [ + "storage_blob_public_access_level_is_disabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that 'Allow Blob Anonymous Access' is Set to 'Disabled'", + "RationaleStatement": "If Allow Blob Anonymous Access is enabled, blobs can be accessed by adding the blob name to the URL to see the contents. An attacker can enumerate a blob using methods, such as brute force, and access them. Exfiltration of data by brute force enumeration of items from a storage account may occur if this setting is set to 'Enabled'.", + "ImpactStatement": "Additional consideration may be required for exceptional circumstances where elements of a storage account require public accessibility. In these circumstances, it is highly recommended that all data stored in the public facing storage account be reviewed for sensitive or potentially compromising data, and that sensitive or compromising data is never stored in these storage accounts.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Settings`, click `Configuration`. 1. Set `Allow Blob Anonymous Access` to `Disabled`. 1. Click `Save`. **Remediate from Powershell** For every storage account in scope, run the following: ``` $storageAccount = Get-AzStorageAccount -ResourceGroupName -Name $storageAccount.AllowBlobPublicAccess = $false Set-AzStorageAccount -InputObject $storageAccount ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Settings`, click `Configuration`. 1. Ensure `Allow Blob Anonymous Access` is set to `Disabled`. **Audit from Azure CLI** For every storage account in scope: ``` az storage account show --name --query allowBlobPublicAccess ``` Ensure that every storage account in scope returns `false` for the allowBlobPublicAccess setting. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [4fa4b6c0-31ca-4c0d-b10d-24b96f62a751](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4fa4b6c0-31ca-4c0d-b10d-24b96f62a751) **- Name:** 'Storage account public access should be disallowed'", + "AdditionalInformation": "Azure Storage accounts that use the classic deployment model will be retired on August 31, 2024.", + "References": "https://learn.microsoft.com/en-us/azure/storage/blobs/anonymous-read-access-prevent?tabs=portal:https://learn.microsoft.com/en-us/azure/storage/blobs/anonymous-read-access-prevent?source=recommendations&tabs=portal:Classic Storage Accounts: https://learn.microsoft.com/en-us/azure/storage/blobs/anonymous-read-access-prevent-classic?tabs=portal", + "DefaultValue": "Disabled" + } + ] + }, + { + "Id": "9.3.9", + "Description": "Ensure Azure Resource Manager Delete Locks are Applied to Azure Storage Accounts", + "Checks": [], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure Azure Resource Manager Delete Locks are Applied to Azure Storage Accounts", + "RationaleStatement": "Applying a _Delete_ lock on storage accounts protects the availability of data by preventing the accidental or unauthorized deletion of the entire storage account. It is a fundamental protective control that can prevent data loss", + "ImpactStatement": "- Prevents the deletion of the Storage account Resource entirely. - Prevents the deletion of the parent Resource Group containing the locked Storage account resource. - Does not prevent other control plane operations, including modification of configurations, network settings, containers, and access. - Does not prevent deletion of containers or other objects within the storage account.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the storage account in the Azure portal. 1. Under the `Settings` section, select `Locks`. 1. Select `Add`. 1. Provide a Name, and choose `Delete` for the type of lock. 1. Add a note about the lock if desired. **Remediate from Azure CLI** Replace the information within <> with appropriate values: ``` az lock create --name \\ --resource-group \\ --resource \\ --lock-type CanNotDelete \\ --resource-type Microsoft.Storage/storageAccounts ``` **Remediate from PowerShell** Replace the information within <> with appropriate values: ``` New-AzResourceLock -LockLevel CanNotDelete ` -LockName ` -ResourceName ` -ResourceType Microsoft.Storage/storageAccounts ` -ResourceGroupName ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the storage account in the Azure portal. 1. For each storage account, under `Settings`, click `Locks`. 1. Ensure that a `Delete` lock exists on the storage account. **Audit from Azure CLI** ``` az lock list --resource-group \\ --resource-name \\ --resource-type Microsoft.Storage/storageAccounts ``` **Audit from PowerShell** ``` Get-AzResourceLock -ResourceGroupName ` -ResourceName ` -ResourceType Microsoft.Storage/storageAccounts ``` **Audit from Azure Policy** There is currently no built-in Microsoft policy to audit resource locks on storage accounts. Custom and community policy definitions can check for the existence of a “Microsoft.Authorization/locks” resource with an AuditIfNotExists effect.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/storage/common/lock-account-resource:https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/lock-resources", + "DefaultValue": "By default, no locks are applied to Azure resources, including storage accounts. Locks must be manually configured after resource creation." + } + ] + }, + { + "Id": "9.3.10", + "Description": "Ensure Azure Resource Manager ReadOnly Locks are Considered for Azure Storage Accounts", + "Checks": [], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure Azure Resource Manager ReadOnly Locks are Considered for Azure Storage Accounts", + "RationaleStatement": "Applying a `ReadOnly` lock on storage accounts protects the confidentiality and availability of data by preventing the accidental or unauthorized deletion of the entire storage account and modification of the account, container properties, or access permissions. It can offer enhanced protection for blob and queue workloads with tradeoffs in usability and compatibility for clients using account shared access keys.", + "ImpactStatement": "- Prevents the deletion of the Storage account Resource entirely. - Prevents the deletion of the parent Resource Group containing the locked Storage account resource. - Prevents clients from obtaining the storage account shared access keys using a `listKeys` operation. - Requires Entra credentials to access blob and queue data in the Portal. - Data in Azure Files or the Table service may be inaccessible to clients using the account shared access keys. - Prevents modification of account properties, network settings, containers, and RBAC assignments. - Does not prevent access using existing account shared access keys issued to clients. - Does not prevent deletion of containers or other objects within the storage account.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the storage account in the Azure portal. 1. Under the `Settings` section, select `Locks`. 1. Select `Add`. 1. Provide a Name, and choose `ReadOnly` for the type of lock. 1. Add a note about the lock if desired. **Remediate from Azure CLI** Replace the information within <> with appropriate values: ``` az lock create --name \\ --resource-group \\ --resource \\ --lock-type ReadOnly \\ --resource-type Microsoft.Storage/storageAccounts ``` **Remediate from PowerShell** Replace the information within <> with appropriate values: ``` New-AzResourceLock -LockLevel ReadOnly ` -LockName ` -ResourceName ` -ResourceType Microsoft.Storage/storageAccounts ` -ResourceGroupName ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the storage account in the Azure portal. 1. For each storage account, under `Settings`, click `Locks`. 1. Ensure that a `ReadOnly` lock exists on the storage account. **Audit from Azure CLI** ``` az lock list --resource-group \\ --resource-name \\ --resource-type Microsoft.Storage/storageAccounts ``` **Audit from PowerShell** ``` Get-AzResourceLock -ResourceGroupName ` -ResourceName ` -ResourceType Microsoft.Storage/storageAccounts ``` **Audit from Azure Policy** There is currently no built-in Microsoft policy to audit resource locks on storage accounts. Custom and community policy definitions can check for the existence of a “Microsoft.Authorization/locks” resource with an AuditIfNotExists effect.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/storage/common/lock-account-resource:https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/lock-resources:https://github.com/Azure/azure-rest-api-specs/tree/main/specification/storage", + "DefaultValue": "By default, no locks are applied to Azure resources, including storage accounts. Locks must be manually configured after resource creation." + } + ] + }, + { + "Id": "9.3.11", + "Description": "Ensure Redundancy is Set to 'geo-redundant storage (GRS)' on Critical Azure Storage Accounts", + "Checks": [ + "storage_geo_redundant_enabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Redundancy is Set to 'geo-redundant storage (GRS)' on Critical Azure Storage Accounts", + "RationaleStatement": "Enabling GRS protects critical data from regional failures by maintaining a copy in a geographically separate location. This significantly reduces the risk of data loss, supports business continuity, and meets high availability requirements for disaster recovery.", + "ImpactStatement": "Enabling geo-redundant storage on Azure storage accounts increases costs due to cross-region data replication.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage accounts`. 1. Click on a storage account. 1. Under `Data management`, click `Redundancy`. 1. From the `Redundancy` drop-down menu, select `Geo-redundant storage (GRS)`. 1. Click `Save`. 1. Repeat steps 1-5 for each storage account requiring remediation. **Remediate from Azure CLI** For each storage account requiring remediation, run the following command to enable geo-redundant storage: ``` az storage account update --resource-group --name --sku Standard_GRS ``` **Remediate from PowerShell** For each storage account requiring remediation, run the following command to enable geo-redundant storage: ``` Set-AzStorageAccount -ResourceGroupName -Name -SkuName Standard_GRS ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage accounts`. 1. Click on a storage account. 1. Under `Data management`, click `Redundancy`. 1. Ensure that `Redundancy` is set to `Geo-redundant storage (GRS)`. 1. Repeat steps 1-4 for each storage account. **Audit from Azure CLI** Run the following command to list storage accounts: ``` az storage account list ``` For each storage account, run the following command: ``` az storage account show --resource-group --name ``` Under `sku`, ensure that `name` is set to `Standard_GRS`. **Audit from PowerShell** Run the following command to list storage accounts: ``` Get-AzStorageAccount ``` Run the following command to get the storage account in a resource group with a given name: ``` $storageAccount = Get-AzStorageAccount -ResourceGroupName -Name ``` Run the following command to get the redundancy setting for the storage account: ``` $storageAccount.SKU.Name ``` Ensure that the command returns `Standard_GRS`. Repeat for each storage account. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [bf045164-79ba-4215-8f95-f8048dc1780b](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fbf045164-79ba-4215-8f95-f8048dc1780b) **- Name:** 'Geo-redundant storage should be enabled for Storage Accounts'", + "AdditionalInformation": "When choosing the best redundancy option, weigh the trade-offs between lower costs and higher availability. Key factors to consider include: - The method of data replication within the primary region. - The replication of data from a primary to a geographically distant secondary region for protection against regional disasters (geo-replication). - The necessity for read access to replicated data in the secondary region during an outage in the primary region (geo-replication with read access).", + "References": "https://learn.microsoft.com/en-us/azure/storage/common/storage-redundancy:https://learn.microsoft.com/en-us/azure/storage/common/redundancy-migration:https://learn.microsoft.com/en-us/cli/azure/storage/account?view=azure-cli-latest#az-storage-account-update:https://learn.microsoft.com/en-us/powershell/module/az.storage/set-azstorageaccount?view=azps-12.4.0:https://learn.microsoft.com/en-us/azure/storage/common/storage-disaster-recovery-guidance", + "DefaultValue": "When creating a storage account in the Azure Portal, the default redundancy setting is geo-redundant storage (GRS). Using the Azure CLI, the default is read-access geo-redundant storage (RA-GRS). In PowerShell, a redundancy level must be explicitly specified during account creation." + } + ] + } + ] +} diff --git a/prowler/compliance/azure/hipaa_azure.json b/prowler/compliance/azure/hipaa_azure.json index 3672218f3b..62ffe2fdad 100644 --- a/prowler/compliance/azure/hipaa_azure.json +++ b/prowler/compliance/azure/hipaa_azure.json @@ -767,6 +767,17 @@ "mysql_flexible_server_minimum_tls_version_12", "mysql_flexible_server_ssl_connection_enabled", "postgresql_flexible_server_enforce_ssl_enabled" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { @@ -819,6 +830,17 @@ "mysql_flexible_server_ssl_connection_enabled", "postgresql_flexible_server_enforce_ssl_enabled", "databricks_workspace_cmk_encryption_enabled" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] } ] diff --git a/prowler/compliance/azure/nis2_azure.json b/prowler/compliance/azure/nis2_azure.json index 5c48c22f6b..0ae814fad7 100644 --- a/prowler/compliance/azure/nis2_azure.json +++ b/prowler/compliance/azure/nis2_azure.json @@ -1133,6 +1133,17 @@ "defender_ensure_defender_for_dns_is_on", "sqlserver_tde_encryption_enabled" ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } + ], "Attributes": [ { "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", @@ -1164,6 +1175,17 @@ "network_udp_internet_access_restricted", "network_watcher_enabled" ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } + ], "Attributes": [ { "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", @@ -1887,6 +1909,17 @@ "sqlserver_tde_encrypted_with_cmk", "sqlserver_tde_encryption_enabled" ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } + ], "Attributes": [ { "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", diff --git a/prowler/compliance/azure/secnumcloud_3.2_azure.json b/prowler/compliance/azure/secnumcloud_3.2_azure.json index aedf2133d4..3f2e8c7603 100644 --- a/prowler/compliance/azure/secnumcloud_3.2_azure.json +++ b/prowler/compliance/azure/secnumcloud_3.2_azure.json @@ -440,6 +440,25 @@ "postgresql_flexible_server_enforce_ssl_enabled", "mysql_flexible_server_ssl_connection_enabled", "mysql_flexible_server_minimum_tls_version_12" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + }, + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ] + } ] }, { diff --git a/prowler/compliance/azure/soc2_azure.json b/prowler/compliance/azure/soc2_azure.json index e0839cedaa..c3b4db6e39 100644 --- a/prowler/compliance/azure/soc2_azure.json +++ b/prowler/compliance/azure/soc2_azure.json @@ -266,6 +266,17 @@ "sqlserver_tde_encryption_enabled", "sqlserver_unrestricted_inbound_access", "storage_secure_transfer_required_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { @@ -310,6 +321,17 @@ "sqlserver_recommended_minimal_tls_version", "storage_ensure_minimum_tls_version_12", "network_subnet_nsg_associated" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { diff --git a/prowler/compliance/cis_controls_8.1.json b/prowler/compliance/cis_controls_8.1.json new file mode 100644 index 0000000000..c03d9e806b --- /dev/null +++ b/prowler/compliance/cis_controls_8.1.json @@ -0,0 +1,4554 @@ +{ + "framework": "CIS-Controls", + "name": "CIS Controls v8.1", + "version": "8.1", + "description": "The CIS Critical Security Controls (CIS Controls) v8.1 are a prioritized set of Safeguards to mitigate the most prevalent cyber-attacks against systems and networks. They are organized into 18 top-level Controls and mapped to three Implementation Groups (IG1, IG2, IG3). This is a cross-provider mapping of Prowler checks to the CIS Controls Safeguards that can be assessed automatically against cloud and platform configurations.", + "icon": "cisecurity", + "attributes_metadata": [ + { + "key": "Section", + "label": "CIS Control", + "type": "str", + "required": true, + "enum": [ + "1. Inventory and Control of Enterprise Assets", + "2. Inventory and Control of Software Assets", + "3. Data Protection", + "4. Secure Configuration of Enterprise Assets and Software", + "5. Account Management", + "6. Access Control Management", + "7. Continuous Vulnerability Management", + "8. Audit Log Management", + "9. Email and Web Browser Protections", + "10. Malware Defenses", + "11. Data Recovery", + "12. Network Infrastructure Management", + "13. Network Monitoring and Defense", + "14. Security Awareness and Skills Training", + "15. Service Provider Management", + "16. Application Software Security", + "17. Incident Response Management", + "18. Penetration Testing" + ], + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "Function", + "label": "Security Function", + "type": "str", + "required": false, + "enum": [ + "Identify", + "Protect", + "Detect", + "Respond", + "Recover", + "Govern" + ], + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "AssetType", + "label": "Asset Type", + "type": "str", + "required": false, + "enum": [ + "Data", + "Devices", + "Documentation", + "Network", + "Software", + "Users" + ], + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "ImplementationGroups", + "label": "Implementation Groups", + "type": "list_str", + "required": false, + "output_formats": { + "csv": true, + "ocsf": true + } + } + ], + "outputs": { + "table_config": { + "group_by": "Section" + }, + "pdf_config": { + "language": "en", + "primary_color": "#cc0000", + "secondary_color": "#7a1f1f", + "bg_color": "#FAF0F0", + "group_by_field": "Section", + "sections": [ + "1. Inventory and Control of Enterprise Assets", + "2. Inventory and Control of Software Assets", + "3. Data Protection", + "4. Secure Configuration of Enterprise Assets and Software", + "5. Account Management", + "6. Access Control Management", + "7. Continuous Vulnerability Management", + "8. Audit Log Management", + "9. Email and Web Browser Protections", + "10. Malware Defenses", + "11. Data Recovery", + "12. Network Infrastructure Management", + "13. Network Monitoring and Defense", + "14. Security Awareness and Skills Training", + "15. Service Provider Management", + "16. Application Software Security", + "17. Incident Response Management", + "18. Penetration Testing" + ], + "section_short_names": { + "1. Inventory and Control of Enterprise Assets": "CIS 1", + "2. Inventory and Control of Software Assets": "CIS 2", + "3. Data Protection": "CIS 3", + "4. Secure Configuration of Enterprise Assets and Software": "CIS 4", + "5. Account Management": "CIS 5", + "6. Access Control Management": "CIS 6", + "7. Continuous Vulnerability Management": "CIS 7", + "8. Audit Log Management": "CIS 8", + "9. Email and Web Browser Protections": "CIS 9", + "10. Malware Defenses": "CIS 10", + "11. Data Recovery": "CIS 11", + "12. Network Infrastructure Management": "CIS 12", + "13. Network Monitoring and Defense": "CIS 13", + "14. Security Awareness and Skills Training": "CIS 14", + "15. Service Provider Management": "CIS 15", + "16. Application Software Security": "CIS 16", + "17. Incident Response Management": "CIS 17", + "18. Penetration Testing": "CIS 18" + }, + "charts": [ + { + "id": "section_compliance", + "type": "horizontal_bar", + "group_by": "Section", + "title": "Compliance Score by CIS Control", + "y_label": "CIS Control", + "x_label": "Compliance %", + "value_source": "compliance_percent", + "color_mode": "by_value" + } + ], + "filter": { + "only_failed": true, + "include_manual": false + } + } + }, + "requirements": [ + { + "id": "1.1", + "name": "Establish and Maintain Detailed Enterprise Asset Inventory", + "description": "Establish and maintain an accurate, detailed, and up-to-date inventory of all enterprise assets with the potential to store or process data, to include: end-user devices (including portable and mobile), network devices, non-computing/IoT devices, and servers. Ensure the inventory records the network address (if static), hardware address, machine name, enterprise asset owner, department for each asset, and whether the asset has been approved to connect to the network. For mobile end-user devices, MDM type tools can support this process, where appropriate. This inventory includes assets connected to the infrastructure physically, virtually, remotely, and those within cloud environments. Additionally, it includes assets that are regularly connected to the enterprise's network infrastructure, even if they are not under control of the enterprise. Review and update the inventory of all enterprise assets bi-annually, or more frequently.", + "attributes": { + "Section": "1. Inventory and Control of Enterprise Assets", + "Function": "Identify", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "config_recorder_all_regions_enabled", + "resourceexplorer2_indexes_found" + ], + "gcp": [ + "iam_cloud_asset_inventory_enabled" + ] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "1.2", + "name": "Address Unauthorized Assets", + "description": "Ensure that a process exists to address unauthorized assets on a weekly basis. The enterprise may choose to remove the asset from the network, deny the asset from connecting remotely to the network, or quarantine the asset.", + "attributes": { + "Section": "1. Inventory and Control of Enterprise Assets", + "Function": "Respond", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "1.3", + "name": "Utilize an Active Discovery Tool", + "description": "Utilize an active discovery tool to identify assets connected to the enterprise's network. Configure the active discovery tool to execute daily, or more frequently.", + "attributes": { + "Section": "1. Inventory and Control of Enterprise Assets", + "Function": "Detect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "1.4", + "name": "Use Dynamic Host Configuration Protocol (DHCP) Logging to Update Enterprise Asset Inventory", + "description": "Use DHCP logging on all DHCP servers or Internet Protocol (IP) address management tools to update the enterprise's asset inventory. Review and use logs to update the enterprise's asset inventory weekly, or more frequently.", + "attributes": { + "Section": "1. Inventory and Control of Enterprise Assets", + "Function": "Identify", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "1.5", + "name": "Use a Passive Asset Discovery Tool", + "description": "Use a passive discovery tool to identify assets connected to the enterprise's network. Review and use scans to update the enterprise's asset inventory at least weekly, or more frequently.", + "attributes": { + "Section": "1. Inventory and Control of Enterprise Assets", + "Function": "Detect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "2.1", + "name": "Establish and Maintain a Software Inventory", + "description": "Establish and maintain a detailed inventory of all licensed software installed on enterprise assets. The software inventory must document the title, publisher, initial install/use date, and business purpose for each entry; where appropriate, include the Uniform Resource Locator (URL), app store(s), version(s), deployment mechanism, decommission date, and number of licenses. Review and update the software inventory bi-annually, or more frequently.", + "attributes": { + "Section": "2. Inventory and Control of Software Assets", + "Function": "Identify", + "AssetType": "Software", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "2.2", + "name": "Ensure Authorized Software is Currently Supported", + "description": "Ensure that only currently supported software is designated as authorized in the software inventory for enterprise assets. If software is unsupported, yet necessary for the fulfillment of the enterprise's mission, document an exception detailing mitigating controls and residual risk acceptance. For any unsupported software without an exception documentation, designate as unauthorized. Review the software list to verify software support at least monthly, or more frequently.", + "attributes": { + "Section": "2. Inventory and Control of Software Assets", + "Function": "Identify", + "AssetType": "Software", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "awslambda_function_using_supported_runtimes", + "ec2_instance_with_outdated_ami", + "eks_cluster_uses_a_supported_version", + "kafka_cluster_uses_latest_version", + "rds_instance_deprecated_engine_version" + ] + } + }, + { + "id": "2.3", + "name": "Address Unauthorized Software", + "description": "Ensure that unauthorized software is either removed from use on enterprise assets or receives a documented exception. Review monthly, or more frequently.", + "attributes": { + "Section": "2. Inventory and Control of Software Assets", + "Function": "Respond", + "AssetType": "Software", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "2.4", + "name": "Utilize Automated Software Inventory Tools", + "description": "Utilize software inventory tools, when possible, throughout the enterprise to automate the discovery and documentation of installed software.", + "attributes": { + "Section": "2. Inventory and Control of Software Assets", + "Function": "Detect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "2.5", + "name": "Allowlist Authorized Software", + "description": "Use technical controls, such as application allowlisting, to ensure that only authorized software can execute or be accessed. Reassess bi-annually, or more frequently.", + "attributes": { + "Section": "2. Inventory and Control of Software Assets", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "googleworkspace": [ + "chat_apps_installation_disabled", + "marketplace_apps_access_restricted", + "security_app_access_restricted" + ], + "m365": [ + "entra_admin_consent_workflow_enabled", + "entra_policy_restricts_user_consent_for_apps", + "entra_thirdparty_integrated_apps_not_allowed" + ] + } + }, + { + "id": "2.6", + "name": "Allowlist Authorized Libraries", + "description": "Use technical controls to ensure that only authorized software libraries, such as specific .dll, .ocx, and .so files, are allowed to load into a system process. Block unauthorized libraries from loading into a system process. Reassess bi-annually, or more frequently.", + "attributes": { + "Section": "2. Inventory and Control of Software Assets", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "2.7", + "name": "Allowlist Authorized Scripts", + "description": "Use technical controls, such as digital signatures and version control, to ensure that only authorized scripts, such as specific .ps1, and .py files are allowed to execute. Block unauthorized scripts from executing. Reassess bi-annually, or more frequently.", + "attributes": { + "Section": "2. Inventory and Control of Software Assets", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "3.1", + "name": "Establish and Maintain a Data Management Process", + "description": "Establish and maintain a documented data management process. In the process, address data sensitivity, data owner, handling of data, data retention limits, and disposal requirements, based on sensitivity and retention standards for the enterprise. Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Govern", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "3.2", + "name": "Establish and Maintain a Data Inventory", + "description": "Establish and maintain a data inventory based on the enterprise's data management process. Inventory sensitive data, at a minimum. Review and update inventory annually, at a minimum, with a priority on sensitive data.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Identify", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "macie_automated_sensitive_data_discovery_enabled", + "macie_is_enabled" + ] + } + }, + { + "id": "3.3", + "name": "Configure Data Access Control Lists", + "description": "Configure data access control lists based on a user's need to know. Apply data access control lists, also known as access permissions, to local and remote file systems, databases, and applications.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "actiontrail_oss_bucket_not_publicly_accessible", + "oss_bucket_not_publicly_accessible", + "rds_instance_no_public_access_whitelist" + ], + "aws": [ + "awslambda_function_not_publicly_accessible", + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", + "cloudwatch_log_group_not_publicly_accessible", + "codebuild_project_not_publicly_accessible", + "dynamodb_table_cross_account_access", + "ec2_ami_public", + "ecr_repositories_not_publicly_accessible", + "efs_access_point_enforce_root_directory", + "efs_access_point_enforce_user_identity", + "efs_mount_target_not_publicly_accessible", + "efs_not_publicly_accessible", + "eventbridge_bus_cross_account_access", + "eventbridge_bus_exposed", + "glacier_vaults_policy_public_access", + "glue_data_catalogs_not_publicly_accessible", + "kms_key_not_publicly_accessible", + "s3_access_point_public_access_block", + "s3_account_level_public_access_blocks", + "s3_bucket_acl_prohibited", + "s3_bucket_cross_account_access", + "s3_bucket_level_public_access_block", + "s3_bucket_policy_public_write_access", + "s3_bucket_public_access", + "s3_bucket_public_list_acl", + "s3_bucket_public_write_acl", + "s3_multi_region_access_point_public_access_block", + "secretsmanager_has_restrictive_resource_policy", + "secretsmanager_not_publicly_accessible", + "ses_identity_not_publicly_accessible", + "sns_topics_not_publicly_accessible", + "sqs_queues_not_publicly_accessible", + "ssm_documents_set_as_public" + ], + "azure": [ + "cosmosdb_account_firewall_use_selected_networks", + "cosmosdb_account_use_aad_and_rbac", + "keyvault_rbac_enabled", + "sqlserver_unrestricted_inbound_access", + "storage_account_key_access_disabled", + "storage_account_public_network_access_disabled", + "storage_blob_public_access_level_is_disabled", + "storage_default_network_access_rule_is_denied" + ], + "gcp": [ + "bigquery_dataset_public_access", + "cloudfunction_function_not_publicly_accessible", + "cloudsql_instance_public_access", + "cloudstorage_bucket_public_access", + "cloudstorage_bucket_uniform_bucket_level_access", + "compute_image_not_publicly_shared", + "kms_key_not_publicly_accessible", + "secretmanager_secret_not_publicly_accessible" + ], + "github": [ + "organization_default_repository_permission_strict" + ], + "googleworkspace": [ + "calendar_external_sharing_primary_calendar", + "calendar_external_sharing_secondary_calendar", + "chat_external_file_sharing_disabled", + "chat_external_messaging_restricted", + "chat_external_spaces_restricted", + "chat_internal_file_sharing_disabled", + "drive_access_checker_recipients_only", + "drive_internal_users_distribute_content", + "drive_publishing_files_disabled", + "drive_shared_drive_disable_download_print_copy", + "drive_shared_drive_managers_cannot_override", + "drive_shared_drive_members_only_access", + "drive_sharing_allowlisted_domains", + "gmail_auto_forwarding_disabled", + "gmail_mail_delegation_disabled", + "groups_external_access_restricted", + "groups_view_conversations_restricted" + ], + "kubernetes": [ + "core_no_secrets_envs", + "rbac_minimize_secret_access" + ], + "m365": [ + "admincenter_external_calendar_sharing_disabled", + "admincenter_groups_not_public_visibility", + "admincenter_organization_customer_lockbox_enabled", + "sharepoint_external_sharing_managed", + "sharepoint_external_sharing_restricted", + "sharepoint_guest_sharing_restricted", + "teams_external_domains_restricted", + "teams_external_file_sharing_restricted", + "teams_external_users_cannot_start_conversations", + "teams_unmanaged_communication_disabled" + ], + "mongodbatlas": [ + "clusters_authentication_enabled" + ], + "openstack": [ + "image_not_publicly_visible", + "image_not_shared_with_multiple_projects", + "objectstorage_container_acl_not_globally_shared", + "objectstorage_container_listing_disabled", + "objectstorage_container_public_read_acl_disabled", + "objectstorage_container_write_acl_restricted" + ], + "oraclecloud": [ + "analytics_instance_access_restricted", + "database_autonomous_database_access_restricted", + "integration_instance_access_restricted", + "objectstorage_bucket_not_publicly_accessible" + ], + "vercel": [ + "project_deployment_protection_enabled", + "project_password_protection_enabled", + "project_production_deployment_protection_enabled", + "team_member_role_least_privilege" + ] + } + }, + { + "id": "3.4", + "name": "Enforce Data Retention", + "description": "Retain data according to the enterprise's documented data management process. Data retention must include both minimum and maximum timelines.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ecr_repositories_lifecycle_policy_enabled", + "kinesis_stream_data_retention_period", + "s3_bucket_lifecycle_enabled" + ], + "gcp": [ + "cloudstorage_bucket_lifecycle_management_enabled", + "cloudstorage_bucket_sufficient_retention_period" + ], + "stackit": [ + "objectstorage_bucket_object_lock_enabled", + "objectstorage_bucket_retention_policy" + ] + } + }, + { + "id": "3.5", + "name": "Securely Dispose of Data", + "description": "Securely dispose of data as outlined in the enterprise's documented data management process. Ensure the disposal process and method are commensurate with the data sensitivity.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "3.6", + "name": "Encrypt Data on End-User Devices", + "description": "Encrypt data on end-user devices containing sensitive data. Example implementations can include: Windows BitLocker®, Apple FileVault®, Linux® dm-crypt.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "3.7", + "name": "Establish and Maintain a Data Classification Scheme", + "description": "Establish and maintain an overall data classification scheme for the enterprise. Enterprises may use labels, such as \"Sensitive,\" \"Confidential,\" and \"Public,\" and classify their data according to those labels. Review and update the classification scheme annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Identify", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "3.8", + "name": "Document Data Flows", + "description": "Document data flows. Data flow documentation includes service provider data flows and should be based on the enterprise's data management process. Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Identify", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "3.9", + "name": "Encrypt Data on Removable Media", + "description": "Encrypt data on removable media.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "3.10", + "name": "Encrypt Sensitive Data in Transit", + "description": "Encrypt sensitive data in transit. Example implementations can include: Transport Layer Security (TLS) and Open Secure Shell (OpenSSH).", + "attributes": { + "Section": "3. Data Protection", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "oss_bucket_secure_transport_enabled", + "rds_instance_ssl_enabled" + ], + "aws": [ + "cloudfront_distributions_https_enabled", + "cloudfront_distributions_origin_traffic_encrypted", + "cloudfront_distributions_using_deprecated_ssl_protocols", + "dms_endpoint_redis_in_transit_encryption_enabled", + "dms_endpoint_ssl_enabled", + "dynamodb_accelerator_cluster_in_transit_encryption_enabled", + "elasticache_redis_cluster_in_transit_encryption_enabled", + "elb_insecure_ssl_ciphers", + "elb_ssl_listeners", + "elb_ssl_listeners_use_acm_certificate", + "elbv2_insecure_ssl_ciphers", + "elbv2_nlb_tls_termination_enabled", + "elbv2_ssl_listeners", + "glue_database_connections_ssl_enabled", + "kafka_cluster_in_transit_encryption_enabled", + "kafka_connector_in_transit_encryption_enabled", + "opensearch_service_domains_https_communications_enforced", + "opensearch_service_domains_node_to_node_encryption_enabled", + "rds_instance_transport_encrypted", + "redshift_cluster_in_transit_encryption_enabled", + "s3_bucket_secure_transport_policy", + "sagemaker_training_jobs_intercontainer_encryption_enabled", + "sns_subscription_not_using_http_endpoints", + "transfer_server_in_transit_encryption_enabled" + ], + "azure": [ + "app_ensure_http_is_redirected_to_https", + "app_minimum_tls_version_12", + "cosmosdb_account_minimum_tls_version", + "mysql_flexible_server_minimum_tls_version_12", + "mysql_flexible_server_ssl_connection_enabled", + "postgresql_flexible_server_enforce_ssl_enabled", + "sqlserver_recommended_minimal_tls_version", + "storage_ensure_minimum_tls_version_12", + "storage_secure_transfer_required_is_enabled", + "storage_smb_channel_encryption_with_secure_algorithm" + ], + "cloudflare": [ + "zone_automatic_https_rewrites_enabled", + "zone_hsts_enabled", + "zone_https_redirect_enabled", + "zone_min_tls_version_secure", + "zone_ssl_strict", + "zone_tls_1_3_enabled", + "zone_universal_ssl_enabled" + ], + "gcp": [ + "cloudsql_instance_ssl_connections" + ], + "kubernetes": [ + "apiserver_etcd_cafile_set", + "apiserver_etcd_tls_config", + "apiserver_kubelet_cert_auth", + "apiserver_kubelet_tls_auth", + "apiserver_strong_ciphers_only", + "apiserver_tls_config", + "etcd_no_auto_tls", + "etcd_no_peer_auto_tls", + "etcd_peer_tls_config", + "etcd_tls_encryption", + "kubelet_strong_ciphers_only", + "kubelet_tls_cert_and_key" + ], + "mongodbatlas": [ + "clusters_tls_enabled" + ], + "oraclecloud": [ + "compute_instance_in_transit_encryption_enabled" + ], + "vercel": [ + "domain_ssl_certificate_valid" + ] + } + }, + { + "id": "3.11", + "name": "Encrypt Sensitive Data at Rest", + "description": "Encrypt sensitive data at rest on servers, applications, and databases. Storage-layer encryption, also known as server-side encryption, meets the minimum requirement of this Safeguard. Additional encryption methods may include application-layer encryption, also known as client-side encryption, where access to the data storage device(s) does not permit access to the plain-text data.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ecs_attached_disk_encrypted", + "ecs_unattached_disk_encrypted", + "rds_instance_tde_enabled", + "rds_instance_tde_key_custom" + ], + "aws": [ + "apigateway_restapi_cache_encrypted", + "athena_workgroup_encryption", + "awslambda_function_env_vars_not_encrypted_with_cmk", + "backup_recovery_point_encrypted", + "backup_vaults_encrypted", + "bedrock_model_invocation_logs_encryption_enabled", + "bedrock_prompt_encrypted_with_cmk", + "cloudtrail_kms_encryption_enabled", + "cloudwatch_log_group_kms_encryption_enabled", + "codebuild_project_s3_logs_encrypted", + "codebuild_report_group_export_encrypted", + "documentdb_cluster_storage_encrypted", + "dynamodb_accelerator_cluster_encryption_enabled", + "dynamodb_tables_kms_cmk_encryption_enabled", + "ec2_ebs_default_encryption", + "ec2_ebs_snapshots_encrypted", + "ec2_ebs_volume_encryption", + "efs_encryption_at_rest_enabled", + "eks_cluster_kms_cmk_encryption_in_secrets_enabled", + "elasticache_redis_cluster_rest_encryption_enabled", + "firehose_stream_encrypted_at_rest", + "glue_data_catalogs_connection_passwords_encryption_enabled", + "glue_data_catalogs_metadata_encryption_enabled", + "glue_development_endpoints_s3_encryption_enabled", + "glue_etl_jobs_amazon_s3_encryption_enabled", + "glue_etl_jobs_cloudwatch_logs_encryption_enabled", + "glue_ml_transform_encrypted_at_rest", + "kafka_cluster_encryption_at_rest_uses_cmk", + "kinesis_stream_encrypted_at_rest", + "neptune_cluster_snapshot_encrypted", + "neptune_cluster_storage_encrypted", + "opensearch_service_domains_encryption_at_rest_enabled", + "rds_cluster_storage_encrypted", + "rds_instance_storage_encrypted", + "rds_snapshots_encrypted", + "redshift_cluster_encrypted_at_rest", + "s3_bucket_default_encryption", + "s3_bucket_kms_encryption", + "sagemaker_notebook_instance_encryption_enabled", + "sagemaker_training_jobs_volume_and_output_encryption_enabled", + "sns_topics_kms_encryption_at_rest_enabled", + "sqs_queues_server_side_encryption_enabled", + "stepfunctions_statemachine_encrypted_with_cmk", + "storagegateway_fileshare_encryption_enabled", + "workspaces_volume_encryption_enabled" + ], + "azure": [ + "databricks_workspace_cmk_encryption_enabled", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "sqlserver_tde_encrypted_with_cmk", + "sqlserver_tde_encryption_enabled", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_infrastructure_encryption_is_enabled", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk" + ], + "gcp": [ + "bigquery_dataset_cmk_encryption", + "bigquery_table_cmk_encryption", + "cloudsql_instance_cmek_encryption_enabled", + "compute_instance_encryption_with_csek_enabled", + "dataproc_encrypted_with_cmks_disabled" + ], + "kubernetes": [ + "apiserver_encryption_provider_config_set" + ], + "linode": [ + "compute_instance_disk_encryption_enabled" + ], + "mongodbatlas": [ + "clusters_encryption_at_rest_enabled" + ], + "openstack": [ + "blockstorage_volume_encryption_enabled" + ], + "oraclecloud": [ + "blockstorage_block_volume_encrypted_with_cmk", + "blockstorage_boot_volume_encrypted_with_cmk", + "filestorage_file_system_encrypted_with_cmk", + "objectstorage_bucket_encrypted_with_cmk" + ], + "vercel": [ + "project_environment_no_secrets_in_plain_type" + ] + } + }, + { + "id": "3.12", + "name": "Segment Data Processing and Storage Based on Sensitivity", + "description": "Segment data processing and storage based on the sensitivity of the data. Do not process sensitive data on enterprise assets intended for lower sensitivity data.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "3.13", + "name": "Deploy a Data Loss Prevention Solution", + "description": "Implement an automated tool, such as a host-based Data Loss Prevention (DLP) tool to identify all sensitive data stored, processed, or transmitted through enterprise assets, including those located onsite or at a remote service provider, and update the enterprise's data inventory.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "aws": [ + "macie_automated_sensitive_data_discovery_enabled", + "macie_is_enabled" + ], + "googleworkspace": [ + "security_dlp_drive_rules_configured" + ], + "openstack": [ + "blockstorage_snapshot_metadata_sensitive_data", + "blockstorage_volume_metadata_sensitive_data", + "compute_instance_metadata_sensitive_data", + "objectstorage_container_metadata_sensitive_data" + ] + } + }, + { + "id": "3.14", + "name": "Log Sensitive Data Access", + "description": "Log sensitive data access, including modification and disposal.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Detect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "oss_bucket_logging_enabled", + "rds_instance_sql_audit_enabled" + ], + "aws": [ + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "opensearch_service_domains_audit_logging_enabled" + ], + "azure": [ + "keyvault_logging_enabled", + "mysql_flexible_server_audit_log_enabled", + "sqlserver_auditing_enabled" + ], + "gcp": [ + "cloudstorage_audit_logs_enabled" + ], + "m365": [ + "exchange_organization_mailbox_auditing_enabled", + "exchange_user_mailbox_auditing_enabled", + "purview_audit_log_search_enabled" + ], + "mongodbatlas": [ + "projects_auditing_enabled" + ], + "oraclecloud": [ + "objectstorage_bucket_logging_enabled" + ] + } + }, + { + "id": "4.1", + "name": "Establish and Maintain a Secure Configuration Process", + "description": "Establish and maintain a documented secure configuration process for enterprise assets (end-user devices, including portable and mobile, non-computing/IoT devices, and servers) and software (operating systems and applications). Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "azure": [ + "policy_ensure_asc_enforcement_enabled" + ], + "kubernetes": [ + "apiserver_request_timeout_set", + "controllermanager_garbage_collection", + "kubelet_conf_file_ownership", + "kubelet_conf_file_permissions", + "kubelet_config_yaml_ownership", + "kubelet_config_yaml_permissions", + "kubelet_event_record_qps", + "kubelet_service_file_ownership_root", + "kubelet_service_file_permissions", + "kubelet_streaming_connection_timeout" + ], + "openstack": [ + "compute_instance_config_drive_enabled", + "compute_instance_locked_status_enabled", + "compute_instance_trusted_image_certificates", + "image_secure_boot_enabled", + "image_signature_verification_enabled" + ] + } + }, + { + "id": "4.2", + "name": "Establish and Maintain a Secure Configuration Process for Network Infrastructure", + "description": "Establish and maintain a documented secure configuration process for network devices. Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "4.3", + "name": "Configure Automatic Session Locking on Enterprise Assets", + "description": "Configure automatic session locking on enterprise assets after a defined period of inactivity. For general purpose operating systems, the period must not exceed 15 minutes. For mobile end-user devices, the period must not exceed 2 minutes.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "appstream_fleet_maximum_session_duration", + "appstream_fleet_session_disconnect_timeout", + "appstream_fleet_session_idle_disconnect_timeout" + ], + "googleworkspace": [ + "security_session_duration_limited" + ], + "m365": [ + "entra_admin_users_sign_in_frequency_enabled", + "entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced", + "entra_intune_enrollment_sign_in_frequency_every_time" + ], + "okta": [ + "application_admin_console_session_idle_timeout_15min", + "signon_global_session_cookies_not_persistent", + "signon_global_session_idle_timeout_15min", + "signon_global_session_lifetime_18h" + ] + } + }, + { + "id": "4.4", + "name": "Implement and Manage a Firewall on Servers", + "description": "Implement and manage a firewall on servers, where supported. Example implementations include a virtual firewall, operating system firewall, or a third-party firewall agent.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ecs_securitygroup_restrict_rdp_internet", + "ecs_securitygroup_restrict_ssh_internet" + ], + "aws": [ + "ec2_instance_port_cassandra_exposed_to_internet", + "ec2_instance_port_cifs_exposed_to_internet", + "ec2_instance_port_elasticsearch_kibana_exposed_to_internet", + "ec2_instance_port_ftp_exposed_to_internet", + "ec2_instance_port_kafka_exposed_to_internet", + "ec2_instance_port_kerberos_exposed_to_internet", + "ec2_instance_port_ldap_exposed_to_internet", + "ec2_instance_port_memcached_exposed_to_internet", + "ec2_instance_port_mongodb_exposed_to_internet", + "ec2_instance_port_mysql_exposed_to_internet", + "ec2_instance_port_oracle_exposed_to_internet", + "ec2_instance_port_postgresql_exposed_to_internet", + "ec2_instance_port_rdp_exposed_to_internet", + "ec2_instance_port_redis_exposed_to_internet", + "ec2_instance_port_sqlserver_exposed_to_internet", + "ec2_instance_port_ssh_exposed_to_internet", + "ec2_instance_port_telnet_exposed_to_internet", + "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip", + "ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_cassandra_7199_9160_8888", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_elasticsearch_kibana_9200_9300_5601", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_ftp_20_21", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_kafka_9092", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_memcached_11211", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mongodb_27017_27018", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mysql_3306", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_oracle_1521_2483", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_postgres_5432", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_redis_6379", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23", + "ec2_securitygroup_allow_wide_open_public_ipv4", + "ec2_securitygroup_default_restrict_traffic", + "ec2_securitygroup_with_many_ingress_egress_rules" + ], + "azure": [ + "network_subnet_nsg_associated", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted" + ], + "gcp": [ + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed" + ], + "kubernetes": [ + "kubelet_manage_iptables" + ], + "linode": [ + "networking_firewall_assigned_to_devices", + "networking_firewall_default_inbound_policy_drop", + "networking_firewall_default_outbound_policy_drop", + "networking_firewall_inbound_rules_configured", + "networking_firewall_outbound_rules_configured", + "networking_firewall_status_enabled" + ], + "mongodbatlas": [ + "organizations_api_access_list_required", + "projects_network_access_list_exposed_to_internet" + ], + "nhn": [ + "compute_instance_security_groups" + ], + "openstack": [ + "compute_instance_security_groups_attached", + "networking_port_security_disabled", + "networking_security_group_allows_all_ingress_from_internet", + "networking_security_group_allows_rdp_from_internet", + "networking_security_group_allows_ssh_from_internet" + ], + "oraclecloud": [ + "network_default_security_list_restricts_traffic", + "network_security_group_ingress_from_internet_to_rdp_port", + "network_security_group_ingress_from_internet_to_ssh_port", + "network_security_list_ingress_from_internet_to_rdp_port", + "network_security_list_ingress_from_internet_to_ssh_port" + ], + "stackit": [ + "iaas_security_group_all_traffic_unrestricted", + "iaas_security_group_database_unrestricted", + "iaas_security_group_rdp_unrestricted", + "iaas_security_group_ssh_unrestricted" + ], + "vercel": [ + "security_custom_rules_configured", + "security_ip_blocking_rules_configured", + "security_waf_enabled" + ] + } + }, + { + "id": "4.5", + "name": "Implement and Manage a Firewall on End-User Devices", + "description": "Implement and manage a host-based firewall or port-filtering tool on end-user devices, with a default-deny rule that drops all traffic except those services and ports that are explicitly allowed.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "4.6", + "name": "Securely Manage Enterprise Assets and Software", + "description": "Securely manage enterprise assets and software. Example implementations include managing configuration through version-controlled Infrastructure-as-Code (IaC) and accessing administrative interfaces over secure network protocols, such as Secure Shell (SSH) and Hypertext Transfer Protocol Secure (HTTPS). Do not use insecure management protocols, such as Telnet (Teletype Network) and HTTP, unless operationally essential.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "oss_bucket_secure_transport_enabled", + "rds_instance_ssl_enabled" + ], + "aws": [ + "autoscaling_group_launch_configuration_requires_imdsv2", + "ec2_instance_account_imdsv2_enabled", + "ec2_instance_imdsv2_enabled", + "ec2_instance_managed_by_ssm", + "ec2_launch_template_imdsv2_required" + ], + "azure": [ + "app_client_certificates_on", + "app_ensure_http_is_redirected_to_https", + "storage_secure_transfer_required_is_enabled", + "vm_linux_enforce_ssh_authentication" + ], + "cloudflare": [ + "zone_automatic_https_rewrites_enabled", + "zone_https_redirect_enabled", + "zone_min_tls_version_secure", + "zone_ssl_strict" + ], + "gcp": [ + "compute_instance_block_project_wide_ssh_keys_disabled", + "compute_project_os_login_enabled" + ], + "kubernetes": [ + "controllermanager_bind_address", + "scheduler_bind_address" + ], + "mongodbatlas": [ + "clusters_tls_enabled" + ], + "openstack": [ + "compute_instance_key_based_authentication" + ], + "oraclecloud": [ + "compute_instance_legacy_metadata_endpoint_disabled" + ] + } + }, + { + "id": "4.7", + "name": "Manage Default Accounts on Enterprise Assets and Software", + "description": "Manage default accounts on enterprise assets and software, such as root, administrator, and other pre-configured vendor accounts. Example implementations can include: disabling default accounts or making them unusable.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ram_no_root_access_key" + ], + "aws": [ + "iam_avoid_root_usage", + "iam_no_root_access_key", + "iam_root_credentials_management_enabled", + "rds_cluster_default_admin", + "rds_instance_default_admin", + "redshift_cluster_non_default_username" + ], + "azure": [ + "aks_cluster_local_accounts_disabled", + "containerregistry_admin_user_disabled", + "cosmosdb_account_use_aad_and_rbac", + "storage_account_key_access_disabled" + ], + "gcp": [ + "compute_instance_default_service_account_in_use", + "compute_instance_default_service_account_in_use_with_full_api_access", + "gke_cluster_no_default_service_account" + ], + "kubernetes": [ + "apiserver_anonymous_requests", + "kubelet_disable_anonymous_auth" + ], + "m365": [ + "exchange_shared_mailbox_sign_in_disabled" + ], + "nhn": [ + "compute_instance_login_user" + ], + "oraclecloud": [ + "identity_tenancy_admin_users_no_api_keys" + ], + "scaleway": [ + "iam_api_keys_no_root_owned" + ] + } + }, + { + "id": "4.8", + "name": "Uninstall or Disable Unnecessary Services on Enterprise Assets and Software", + "description": "Uninstall or disable unnecessary services on enterprise assets and software, such as an unused file sharing service, web application module, or service function.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "cs_kubernetes_dashboard_disabled" + ], + "azure": [ + "app_ftp_deployment_disabled", + "app_function_ftps_deployment_disabled" + ], + "gcp": [ + "compute_instance_ip_forwarding_is_enabled", + "compute_instance_serial_ports_in_use" + ], + "googleworkspace": [ + "chat_incoming_webhooks_disabled", + "drive_desktop_access_disabled", + "gmail_per_user_outbound_gateway_disabled", + "gmail_pop_imap_access_disabled", + "security_less_secure_apps_disabled", + "sites_service_disabled" + ], + "kubernetes": [ + "apiserver_disable_profiling", + "controllermanager_disable_profiling", + "kubelet_disable_read_only_port", + "scheduler_profiling" + ], + "m365": [ + "exchange_transport_config_smtp_auth_disabled", + "teams_email_sending_to_channel_disabled" + ], + "oraclecloud": [ + "compute_instance_legacy_metadata_endpoint_disabled" + ], + "vercel": [ + "project_auto_expose_system_env_disabled", + "project_directory_listing_disabled" + ] + } + }, + { + "id": "4.9", + "name": "Configure Trusted DNS Servers on Enterprise Assets", + "description": "Configure trusted DNS servers on network infrastructure. Example implementations include configuring network devices to use enterprise-controlled DNS servers and/or reputable externally accessible DNS servers.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "cloudflare": [ + "zone_dnssec_enabled" + ] + } + }, + { + "id": "4.10", + "name": "Enforce Automatic Device Lockout on Portable End-User Devices", + "description": "Enforce automatic device lockout following a predetermined threshold of local failed authentication attempts on portable end-user devices, where supported. For laptops, do not allow more than 20 failed authentication attempts; for tablets and smartphones, no more than 10 failed authentication attempts. Example implementations include Microsoft® InTune Device Lock and Apple® Configuration Profile maxFailedAttempts.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "4.11", + "name": "Enforce Remote Wipe Capability on Portable End-User Devices", + "description": "Remotely wipe enterprise data from enterprise-owned portable end-user devices when deemed appropriate such as lost or stolen devices, or when an individual no longer supports the enterprise.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "4.12", + "name": "Separate Enterprise Workspaces on Mobile End-User Devices", + "description": "Ensure separate enterprise workspaces are used on mobile end-user devices, where supported. Example implementations include using an Apple® Configuration Profile or Android™ Work Profile to separate enterprise applications and data from personal applications and data.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "5.1", + "name": "Establish and Maintain an Inventory of Accounts", + "description": "Establish and maintain an inventory of all accounts managed in the enterprise. The inventory must at a minimum include user, administrator, and service accounts. The inventory, at a minimum, should contain the person's name, username, start/stop dates, and department. Validate that all active accounts are authorized, on a recurring schedule at a minimum quarterly, or more frequently.", + "attributes": { + "Section": "5. Account Management", + "Function": "Identify", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "5.2", + "name": "Use Unique Passwords", + "description": "Use unique passwords for all enterprise assets. Best practice implementation includes, at a minimum, an 8-character password for accounts using Multi-Factor Authentication (MFA) and a 14-character password for accounts not using MFA.", + "attributes": { + "Section": "5. Account Management", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ram_password_policy_lowercase", + "ram_password_policy_minimum_length", + "ram_password_policy_number", + "ram_password_policy_password_reuse_prevention", + "ram_password_policy_symbol", + "ram_password_policy_uppercase" + ], + "aws": [ + "cognito_user_pool_password_policy_lowercase", + "cognito_user_pool_password_policy_minimum_length_14", + "cognito_user_pool_password_policy_number", + "cognito_user_pool_password_policy_symbol", + "cognito_user_pool_password_policy_uppercase", + "iam_password_policy_lowercase", + "iam_password_policy_minimum_length_14", + "iam_password_policy_number", + "iam_password_policy_reuse_24", + "iam_password_policy_symbol", + "iam_password_policy_uppercase" + ], + "googleworkspace": [ + "security_password_policy_strong" + ], + "okta": [ + "authenticator_password_common_password_check", + "authenticator_password_complexity_lowercase", + "authenticator_password_complexity_number", + "authenticator_password_complexity_symbol", + "authenticator_password_complexity_uppercase", + "authenticator_password_history_5", + "authenticator_password_minimum_length_15" + ], + "oraclecloud": [ + "identity_password_policy_minimum_length_14", + "identity_password_policy_prevents_reuse" + ] + } + }, + { + "id": "5.3", + "name": "Disable Dormant Accounts", + "description": "Delete or disable any dormant accounts after a period of 45 days of inactivity, where supported.", + "attributes": { + "Section": "5. Account Management", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ram_user_console_access_unused" + ], + "aws": [ + "iam_user_accesskey_unused", + "iam_user_console_access_unused" + ], + "azure": [ + "entra_user_with_recent_sign_in" + ], + "gcp": [ + "iam_sa_user_managed_key_unused", + "iam_service_account_unused" + ], + "okta": [ + "user_inactivity_automation_35d_enabled" + ], + "vercel": [ + "authentication_no_stale_tokens" + ] + } + }, + { + "id": "5.4", + "name": "Restrict Administrator Privileges to Dedicated Administrator Accounts", + "description": "Restrict administrator privileges to dedicated administrator accounts on enterprise assets. Conduct general computing activities, such as internet browsing, email, and productivity suite use, from the user's primary, non-privileged account.", + "attributes": { + "Section": "5. Account Management", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ram_policy_no_administrative_privileges" + ], + "aws": [ + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_group_administrator_access_policy", + "iam_inline_policy_no_administrative_privileges", + "iam_role_administratoraccess_policy", + "iam_user_administrator_access_policy" + ], + "azure": [ + "app_function_identity_without_admin_privileges", + "entra_global_admin_in_less_than_five_users", + "iam_role_user_access_admin_restricted", + "iam_subscription_roles_owner_custom_not_created" + ], + "gcp": [ + "iam_sa_no_administrative_privileges" + ], + "googleworkspace": [ + "directory_super_admin_count", + "directory_super_admin_only_admin_roles" + ], + "kubernetes": [ + "rbac_cluster_admin_usage" + ], + "m365": [ + "admincenter_users_admins_reduced_license_footprint", + "admincenter_users_between_two_and_four_global_admins", + "entra_admin_portals_access_restriction", + "entra_admin_users_cloud_only" + ], + "okta": [ + "apitoken_not_super_admin" + ], + "oraclecloud": [ + "identity_iam_admins_cannot_update_tenancy_admins", + "identity_service_level_admins_exist", + "identity_tenancy_admin_permissions_limited", + "identity_tenancy_admin_users_no_api_keys" + ], + "scaleway": [ + "iam_api_keys_no_root_owned" + ], + "vercel": [ + "team_member_role_least_privilege" + ] + } + }, + { + "id": "5.5", + "name": "Establish and Maintain an Inventory of Service Accounts", + "description": "Establish and maintain an inventory of service accounts. The inventory, at a minimum, must contain department owner, review date, and purpose. Perform service account reviews to validate that all active accounts are authorized, on a recurring schedule at a minimum quarterly, or more frequently.", + "attributes": { + "Section": "5. Account Management", + "Function": "Identify", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "5.6", + "name": "Centralize Account Management", + "description": "Centralize account management through a directory or identity service.", + "attributes": { + "Section": "5. Account Management", + "Function": "Govern", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "iam_check_saml_providers_sts" + ], + "azure": [ + "cosmosdb_account_use_aad_and_rbac", + "postgresql_flexible_server_entra_id_authentication_enabled", + "sqlserver_azuread_administrator_enabled", + "storage_default_to_entra_authorization_enabled" + ], + "vercel": [ + "team_directory_sync_enabled", + "team_saml_sso_enabled" + ] + } + }, + { + "id": "6.1", + "name": "Establish an Access Granting Process", + "description": "Establish and follow a documented process, preferably automated, for granting access to enterprise assets upon new hire or role change of a user.", + "attributes": { + "Section": "6. Access Control Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "vercel": [ + "team_directory_sync_enabled" + ] + } + }, + { + "id": "6.2", + "name": "Establish an Access Revoking Process", + "description": "Establish and follow a process, preferably automated, for revoking access to enterprise assets, through disabling accounts immediately upon termination, rights revocation, or role change of a user. Disabling accounts, instead of deleting accounts, may be necessary to preserve audit trails.", + "attributes": { + "Section": "6. Access Control Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "vercel": [ + "team_directory_sync_enabled" + ] + } + }, + { + "id": "6.3", + "name": "Require MFA for Externally-Exposed Applications", + "description": "Require all externally-exposed enterprise or third-party applications to enforce MFA, where supported. Enforcing MFA through a directory service or SSO provider is a satisfactory implementation of this Safeguard.", + "attributes": { + "Section": "6. Access Control Management", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ram_user_mfa_enabled_console_access" + ], + "aws": [ + "cognito_user_pool_mfa_enabled", + "iam_user_hardware_mfa_enabled", + "iam_user_mfa_enabled_console_access" + ], + "azure": [ + "entra_authentication_methods_policy_strong_auth_enforced", + "entra_non_privileged_user_has_mfa", + "entra_security_defaults_enabled" + ], + "github": [ + "organization_members_mfa_required" + ], + "googleworkspace": [ + "security_2sv_enforced", + "security_advanced_protection_configured" + ], + "linode": [ + "administration_user_2fa_enabled" + ], + "m365": [ + "entra_conditional_access_policy_mfa_enforced_for_guest_users", + "entra_users_mfa_capable", + "entra_users_mfa_enabled" + ], + "mongodbatlas": [ + "organizations_mfa_required" + ], + "okta": [ + "application_dashboard_mfa_required", + "application_dashboard_phishing_resistant_authentication" + ], + "oraclecloud": [ + "identity_user_mfa_enabled_console_access" + ] + } + }, + { + "id": "6.4", + "name": "Require MFA for Remote Network Access", + "description": "Require MFA for remote network access.", + "attributes": { + "Section": "6. Access Control Management", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "directoryservice_radius_server_security_protocol", + "directoryservice_supported_mfa_radius_enabled" + ], + "azure": [ + "entra_user_with_vm_access_has_mfa", + "entra_security_defaults_enabled", + "entra_non_privileged_user_has_mfa" + ], + "gcp": [ + "compute_project_os_login_2fa_enabled" + ], + "github": [ + "organization_members_mfa_required" + ], + "linode": [ + "administration_user_2fa_enabled" + ], + "m365": [ + "entra_users_mfa_enabled", + "entra_legacy_authentication_blocked" + ], + "mongodbatlas": [ + "organizations_mfa_required" + ] + } + }, + { + "id": "6.5", + "name": "Require MFA for Administrative Access", + "description": "Require MFA for all administrative access accounts, where supported, on all enterprise assets, whether managed on-site or through a service provider.", + "attributes": { + "Section": "6. Access Control Management", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ram_user_mfa_enabled_console_access" + ], + "aws": [ + "iam_administrator_access_with_mfa", + "iam_root_hardware_mfa_enabled", + "iam_root_mfa_enabled" + ], + "azure": [ + "entra_conditional_access_policy_require_mfa_for_admin_portals", + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_privileged_user_has_mfa", + "entra_user_with_vm_access_has_mfa" + ], + "github": [ + "organization_members_mfa_required" + ], + "googleworkspace": [ + "security_2sv_hardware_keys_admins" + ], + "linode": [ + "administration_user_2fa_enabled" + ], + "m365": [ + "entra_admin_users_mfa_enabled", + "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_break_glass_account_fido2_security_key_registered" + ], + "mongodbatlas": [ + "organizations_mfa_required" + ], + "okta": [ + "application_admin_console_mfa_required", + "application_admin_console_phishing_resistant_authentication" + ], + "vercel": [ + "team_saml_sso_enforced", + "team_saml_sso_enabled" + ] + } + }, + { + "id": "6.6", + "name": "Establish and Maintain an Inventory of Authentication and Authorization Systems", + "description": "Establish and maintain an inventory of the enterprise's authentication and authorization systems, including those hosted on-site or at a remote service provider. Review and update the inventory, at a minimum, annually, or more frequently.", + "attributes": { + "Section": "6. Access Control Management", + "Function": "Identify", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "6.7", + "name": "Centralize Access Control", + "description": "Centralize access control for all enterprise assets through a directory service or SSO provider, where supported.", + "attributes": { + "Section": "6. Access Control Management", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "dms_endpoint_neptune_iam_authorization_enabled", + "iam_check_saml_providers_sts", + "neptune_cluster_iam_authentication_enabled", + "opensearch_service_domains_use_cognito_authentication_for_kibana", + "rds_cluster_iam_authentication_enabled", + "rds_instance_iam_authentication_enabled", + "sagemaker_domain_sso_configured" + ], + "azure": [ + "aks_cluster_local_accounts_disabled", + "cosmosdb_account_use_aad_and_rbac", + "postgresql_flexible_server_entra_id_authentication_enabled", + "sqlserver_azuread_administrator_enabled" + ], + "m365": [ + "entra_all_apps_conditional_access_coverage", + "entra_conditional_access_policy_all_apps_all_users", + "entra_password_hash_sync_enabled" + ], + "vercel": [ + "team_directory_sync_enabled", + "team_saml_sso_enabled", + "team_saml_sso_enforced" + ] + } + }, + { + "id": "6.8", + "name": "Define and Maintain Role-Based Access Control", + "description": "Define and maintain role-based access control, through determining and documenting the access rights necessary for each role within the enterprise to successfully carry out its assigned duties. Perform access control reviews of enterprise assets to validate that all privileges are authorized, on a recurring schedule at a minimum annually, or more frequently.", + "attributes": { + "Section": "6. Access Control Management", + "Function": "Govern", + "AssetType": "Users", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "cs_kubernetes_rbac_enabled", + "ram_policy_attached_only_to_group_or_roles", + "ram_policy_no_administrative_privileges" + ], + "aws": [ + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings", + "bedrock_agent_role_least_privilege", + "bedrock_api_key_no_administrative_privileges", + "ec2_instance_profile_attached", + "iam_inline_policy_allows_privilege_escalation", + "iam_inline_policy_no_full_access_to_cloudtrail", + "iam_inline_policy_no_full_access_to_kms", + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_no_custom_policy_permissive_role_assumption", + "iam_policy_allows_privilege_escalation", + "iam_policy_no_full_access_to_cloudtrail", + "iam_policy_no_full_access_to_kms", + "iam_policy_no_wildcard_marketplace_subscribe", + "iam_role_cross_account_readonlyaccess_policy", + "iam_role_cross_service_confused_deputy_prevention", + "iam_user_with_temporary_credentials" + ], + "azure": [ + "aks_cluster_rbac_enabled", + "cosmosdb_account_use_aad_and_rbac", + "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", + "keyvault_rbac_enabled" + ], + "gcp": [ + "iam_account_access_approval_enabled", + "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" + ], + "github": [ + "organization_default_repository_permission_strict", + "organization_repository_creation_limited", + "organization_repository_deletion_limited" + ], + "kubernetes": [ + "apiserver_auth_mode_include_node", + "apiserver_auth_mode_include_rbac", + "apiserver_auth_mode_not_always_allow", + "controllermanager_service_account_credentials", + "kubelet_authorization_mode", + "rbac_minimize_csr_approval_access", + "rbac_minimize_node_proxy_subresource_access", + "rbac_minimize_pod_creation_access", + "rbac_minimize_pv_creation_access", + "rbac_minimize_service_account_token_creation", + "rbac_minimize_webhook_config_access", + "rbac_minimize_wildcard_use_roles" + ], + "m365": [ + "entra_admin_portals_access_restriction", + "entra_app_registration_no_unused_privileged_permissions", + "entra_policy_guest_users_access_restrictions", + "entra_service_principal_privileged_role_no_owners" + ], + "oraclecloud": [ + "identity_no_resources_in_root_compartment", + "identity_non_root_compartment_exists", + "identity_service_level_admins_exist", + "identity_storage_service_level_admins_scoped", + "identity_tenancy_admin_permissions_limited" + ], + "vercel": [ + "team_member_role_least_privilege" + ] + }, + "config_requirements": [ + { + "Check": "accessanalyzer_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "7.1", + "name": "Establish and Maintain a Vulnerability Management Process", + "description": "Establish and maintain a documented vulnerability management process for enterprise assets. Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "7. Continuous Vulnerability Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "7.2", + "name": "Establish and Maintain a Remediation Process", + "description": "Establish and maintain a risk-based remediation strategy documented in a remediation process, with monthly, or more frequent, reviews.", + "attributes": { + "Section": "7. Continuous Vulnerability Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "7.3", + "name": "Perform Automated Operating System Patch Management", + "description": "Perform operating system updates on enterprise assets through automated patch management on a monthly, or more frequent, basis.", + "attributes": { + "Section": "7. Continuous Vulnerability Management", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ecs_instance_latest_os_patches_applied" + ], + "aws": [ + "ssm_managed_compliant_patching" + ], + "azure": [ + "aks_cluster_auto_upgrade_enabled", + "defender_ensure_system_updates_are_applied" + ] + } + }, + { + "id": "7.4", + "name": "Perform Automated Application Patch Management", + "description": "Perform application updates on enterprise assets through automated patch management on a monthly, or more frequent, basis.", + "attributes": { + "Section": "7. Continuous Vulnerability Management", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "dms_instance_minor_version_upgrade_enabled", + "elasticache_redis_cluster_auto_minor_version_upgrades", + "elasticbeanstalk_environment_managed_updates_enabled", + "memorydb_cluster_auto_minor_version_upgrades", + "mq_broker_auto_minor_version_upgrades", + "rds_cluster_minor_version_upgrade_enabled", + "rds_instance_minor_version_upgrade_enabled", + "redshift_cluster_automatic_upgrades" + ], + "azure": [ + "app_ensure_java_version_is_latest", + "app_ensure_php_version_is_latest", + "app_ensure_python_version_is_latest", + "app_function_latest_runtime_version" + ] + } + }, + { + "id": "7.5", + "name": "Perform Automated Vulnerability Scans of Internal Enterprise Assets", + "description": "Perform automated vulnerability scans of internal enterprise assets on a quarterly, or more frequent, basis. Conduct both authenticated and unauthenticated scans.", + "attributes": { + "Section": "7. Continuous Vulnerability Management", + "Function": "Identify", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled" + ], + "aws": [ + "ecr_registry_scan_images_on_push_enabled", + "ecr_repositories_scan_images_on_push_enabled", + "ecr_repositories_scan_vulnerabilities_in_latest_image", + "inspector2_is_enabled" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_scan_enabled", + "sqlserver_va_emails_notifications_admins_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "sqlserver_va_scan_reports_configured", + "sqlserver_vulnerability_assessment_enabled" + ], + "gcp": [ + "artifacts_container_analysis_enabled", + "gcr_container_scanning_enabled" + ], + "github": [ + "repository_dependency_scanning_enabled" + ] + } + }, + { + "id": "7.6", + "name": "Perform Automated Vulnerability Scans of Externally-Exposed Enterprise Assets", + "description": "Perform automated vulnerability scans of externally-exposed enterprise assets. Perform scans on a monthly, or more frequent, basis.", + "attributes": { + "Section": "7. Continuous Vulnerability Management", + "Function": "Identify", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "inspector2_is_enabled" + ], + "azure": [ + "network_public_ip_shodan" + ] + } + }, + { + "id": "7.7", + "name": "Remediate Detected Vulnerabilities", + "description": "Remediate detected vulnerabilities in software through processes and tooling on a monthly, or more frequent, basis, based on the remediation process.", + "attributes": { + "Section": "7. Continuous Vulnerability Management", + "Function": "Respond", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "inspector2_active_findings_exist" + ], + "azure": [ + "defender_container_images_resolved_vulnerabilities" + ] + } + }, + { + "id": "8.1", + "name": "Establish and Maintain an Audit Log Management Process", + "description": "Establish and maintain a documented audit log management process that defines the enterprise's logging requirements. At a minimum, address the collection, review, and retention of audit logs for enterprise assets. Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "8.2", + "name": "Collect Audit Logs", + "description": "Collect audit logs. Ensure that logging, per the enterprise's audit log management process, has been enabled across enterprise assets.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Detect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "actiontrail_multi_region_enabled", + "cs_kubernetes_log_service_enabled", + "oss_bucket_logging_enabled", + "rds_instance_sql_audit_enabled", + "vpc_flow_logs_enabled" + ], + "aws": [ + "apigateway_restapi_logging_enabled", + "apigatewayv2_api_access_logging_enabled", + "appsync_field_level_logging_enabled", + "athena_workgroup_logging_enabled", + "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled", + "bedrock_model_invocation_logging_enabled", + "cloudfront_distributions_logging_enabled", + "cloudtrail_bedrock_logging_enabled", + "cloudtrail_cloudwatch_logging_enabled", + "cloudtrail_logs_s3_bucket_access_logging_enabled", + "cloudtrail_multi_region_enabled", + "cloudtrail_multi_region_enabled_logging_management_events", + "codebuild_project_logging_enabled", + "config_recorder_all_regions_enabled", + "datasync_task_logging_enabled", + "directoryservice_directory_log_forwarding_enabled", + "dms_replication_task_source_logging_enabled", + "dms_replication_task_target_logging_enabled", + "documentdb_cluster_cloudwatch_log_export", + "ec2_client_vpn_endpoint_connection_logging_enabled", + "ecs_task_definitions_logging_enabled", + "eks_control_plane_logging_all_types_enabled", + "elasticbeanstalk_environment_cloudwatch_logging_enabled", + "elb_logging_enabled", + "elbv2_logging_enabled", + "mq_broker_logging_enabled", + "neptune_cluster_integration_cloudwatch_logs", + "networkfirewall_logging_enabled", + "opensearch_service_domains_cloudwatch_logging_enabled", + "rds_cluster_integration_cloudwatch_logs", + "rds_instance_integration_cloudwatch_logs", + "redshift_cluster_audit_logging", + "s3_bucket_server_access_logging_enabled", + "stepfunctions_statemachine_logging_enabled", + "vpc_flow_logs_enabled", + "waf_global_webacl_logging_enabled", + "wafv2_webacl_logging_enabled" + ], + "azure": [ + "app_function_application_insights_enabled", + "app_http_logs_enabled", + "appinsights_ensure_is_configured", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "keyvault_logging_enabled", + "monitor_diagnostic_settings_exists", + "mysql_flexible_server_audit_log_enabled", + "network_flow_log_captured_sent", + "postgresql_flexible_server_log_checkpoints_on", + "postgresql_flexible_server_log_connections_on", + "sqlserver_auditing_enabled" + ], + "gcp": [ + "cloudstorage_audit_logs_enabled", + "cloudstorage_bucket_logging_enabled", + "compute_loadbalancer_logging_enabled", + "iam_audit_logs_enabled", + "logging_sink_created" + ], + "googleworkspace": [ + "gmail_comprehensive_mail_storage_enabled" + ], + "kubernetes": [ + "apiserver_audit_log_path_set" + ], + "m365": [ + "exchange_mailbox_audit_bypass_disabled", + "exchange_organization_mailbox_auditing_enabled", + "exchange_user_mailbox_auditing_enabled", + "purview_audit_log_search_enabled" + ], + "mongodbatlas": [ + "projects_auditing_enabled" + ], + "okta": [ + "systemlog_streaming_enabled" + ], + "oraclecloud": [ + "network_vcn_subnet_flow_logs_enabled", + "objectstorage_bucket_logging_enabled" + ] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "8.3", + "name": "Ensure Adequate Audit Log Storage", + "description": "Ensure that logging destinations maintain adequate storage to comply with the enterprise's audit log management process.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "kubernetes": [ + "apiserver_audit_log_maxbackup_set", + "apiserver_audit_log_maxsize_set" + ] + } + }, + { + "id": "8.4", + "name": "Standardize Time Synchronization", + "description": "Standardize time synchronization. Configure at least two synchronized time sources across enterprise assets, where supported.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "8.5", + "name": "Collect Detailed Audit Logs", + "description": "Configure detailed audit logging for enterprise assets containing sensitive data. Include event source, date, username, timestamp, source addresses, destination addresses, and other useful elements that could assist in a forensic investigation.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Detect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "rds_instance_postgresql_log_connections_enabled", + "rds_instance_postgresql_log_disconnections_enabled", + "rds_instance_postgresql_log_duration_enabled" + ], + "aws": [ + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "opensearch_service_domains_audit_logging_enabled" + ], + "azure": [ + "monitor_diagnostic_setting_with_appropriate_categories", + "mysql_flexible_server_audit_log_connection_activated", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on" + ], + "gcp": [ + "cloudsql_instance_postgres_enable_pgaudit_flag", + "cloudsql_instance_postgres_log_connections_flag", + "cloudsql_instance_postgres_log_disconnections_flag", + "cloudsql_instance_postgres_log_error_verbosity_flag", + "cloudsql_instance_postgres_log_min_duration_statement_flag", + "cloudsql_instance_postgres_log_min_error_statement_flag", + "cloudsql_instance_postgres_log_min_messages_flag", + "cloudsql_instance_postgres_log_statement_flag" + ], + "m365": [ + "exchange_user_mailbox_auditing_enabled" + ], + "mongodbatlas": [ + "projects_auditing_enabled" + ] + } + }, + { + "id": "8.6", + "name": "Collect DNS Query Audit Logs", + "description": "Collect DNS query audit logs on enterprise assets, where appropriate and supported.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Detect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "route53_public_hosted_zones_cloudwatch_logging_enabled" + ], + "gcp": [ + "compute_network_dns_logging_enabled" + ] + } + }, + { + "id": "8.7", + "name": "Collect URL Request Audit Logs", + "description": "Collect URL request audit logs on enterprise assets, where appropriate and supported.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Detect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "azure": [ + "app_http_logs_enabled" + ], + "gcp": [ + "compute_loadbalancer_logging_enabled" + ] + } + }, + { + "id": "8.8", + "name": "Collect Command-Line Audit Logs", + "description": "Collect command-line audit logs. Example implementations include collecting audit logs from PowerShell®, BASH™, and remote administrative terminals.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Detect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "8.9", + "name": "Centralize Audit Logs", + "description": "Centralize, to the extent possible, audit log collection and retention across enterprise assets in accordance with the documented audit log management process. Example implementations primarily include leveraging a SIEM tool to centralize multiple log sources.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Detect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "cloudtrail_cloudwatch_logging_enabled", + "config_delegated_admin_and_org_aggregator_all_regions" + ], + "azure": [ + "defender_auto_provisioning_log_analytics_agent_vms_on", + "network_flow_log_captured_sent" + ], + "gcp": [ + "logging_sink_created" + ], + "m365": [ + "purview_audit_log_search_enabled" + ], + "okta": [ + "systemlog_streaming_enabled" + ] + } + }, + { + "id": "8.10", + "name": "Retain Audit Logs", + "description": "Retain audit logs across enterprise assets for a minimum of 90 days.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "rds_instance_sql_audit_retention", + "sls_logstore_retention_period" + ], + "aws": [ + "cloudwatch_log_group_retention_policy_specific_days_enabled" + ], + "azure": [ + "network_flow_log_more_than_90_days", + "postgresql_flexible_server_log_retention_days_greater_3", + "sqlserver_auditing_retention_90_days" + ], + "gcp": [ + "cloudstorage_bucket_log_retention_policy_lock" + ], + "kubernetes": [ + "apiserver_audit_log_maxage_set" + ], + "m365": [ + "exchange_user_mailbox_auditing_enabled" + ], + "oraclecloud": [ + "audit_log_retention_period_365_days" + ] + } + }, + { + "id": "8.11", + "name": "Conduct Audit Log Reviews", + "description": "Conduct reviews of audit logs to detect anomalies or abnormal events that could indicate a potential threat. Conduct reviews on a weekly, or more frequent, basis.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Detect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "sls_cloud_firewall_changes_alert_enabled", + "sls_customer_created_cmk_changes_alert_enabled", + "sls_management_console_authentication_failures_alert_enabled", + "sls_management_console_signin_without_mfa_alert_enabled", + "sls_oss_bucket_policy_changes_alert_enabled", + "sls_oss_permission_changes_alert_enabled", + "sls_ram_role_changes_alert_enabled", + "sls_rds_instance_configuration_changes_alert_enabled", + "sls_root_account_usage_alert_enabled", + "sls_security_group_changes_alert_enabled", + "sls_unauthorized_api_calls_alert_enabled", + "sls_vpc_changes_alert_enabled", + "sls_vpc_network_route_changes_alert_enabled" + ], + "aws": [ + "cloudtrail_insights_exist", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_security_group_changes", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "cloudwatch_log_metric_filter_unauthorized_api_calls" + ], + "azure": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr", + "monitor_alert_service_health_exists" + ], + "gcp": [ + "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" + ], + "googleworkspace": [ + "rules_admin_privilege_granted_alert_configured", + "rules_gmail_employee_spoofing_alert_configured", + "rules_government_backed_attacks_alert_configured", + "rules_leaked_password_alert_configured", + "rules_password_changed_alert_configured", + "rules_suspicious_activity_suspension_alert_configured", + "rules_suspicious_login_alert_configured", + "rules_suspicious_programmatic_login_alert_configured" + ], + "oraclecloud": [ + "events_rule_cloudguard_problems", + "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" + ] + } + }, + { + "id": "8.12", + "name": "Collect Service Provider Logs", + "description": "Collect service provider logs, where supported. Example implementations include collecting authentication and authorization events, data creation and disposal events, and user management events.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Detect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "actiontrail_multi_region_enabled" + ], + "aws": [ + "cloudtrail_multi_region_enabled", + "cloudtrail_multi_region_enabled_logging_management_events" + ], + "azure": [ + "monitor_diagnostic_settings_exists" + ], + "gcp": [ + "iam_audit_logs_enabled" + ], + "m365": [ + "purview_audit_log_search_enabled" + ], + "okta": [ + "systemlog_streaming_enabled" + ] + } + }, + { + "id": "9.1", + "name": "Ensure Use of Only Fully Supported Browsers and Email Clients", + "description": "Ensure only fully supported browsers and email clients are allowed to execute in the enterprise, only using the latest version of browsers and email clients provided through the vendor.", + "attributes": { + "Section": "9. Email and Web Browser Protections", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "9.2", + "name": "Use DNS Filtering Services", + "description": "Use DNS filtering services on all end-user devices, including remote and on-premises assets, to block access to known malicious domains.", + "attributes": { + "Section": "9. Email and Web Browser Protections", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "9.3", + "name": "Maintain and Enforce Network-Based URL Filters", + "description": "Enforce and update network-based URL filters to limit an enterprise asset from connecting to potentially malicious or unapproved websites. Example implementations include category-based filtering, reputation-based filtering, or through the use of block lists. Enforce filters for all enterprise assets.", + "attributes": { + "Section": "9. Email and Web Browser Protections", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "googleworkspace": [ + "gmail_shortener_scanning_enabled", + "gmail_untrusted_link_warnings_enabled" + ], + "m365": [ + "defender_safelinks_policy_enabled" + ] + } + }, + { + "id": "9.4", + "name": "Restrict Unnecessary or Unauthorized Browser and Email Client Extensions", + "description": "Restrict, either through uninstalling or disabling, any unauthorized or unnecessary browser or email client plugins, extensions, and add-on applications.", + "attributes": { + "Section": "9. Email and Web Browser Protections", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "m365": [ + "exchange_mailbox_policy_additional_storage_restricted", + "exchange_roles_assignment_policy_addins_disabled" + ] + } + }, + { + "id": "9.5", + "name": "Implement DMARC", + "description": "To lower the chance of spoofed or modified emails from valid domains, implement DMARC policy and verification, starting with implementing the Sender Policy Framework (SPF) and the DomainKeys Identified Mail (DKIM) standards.", + "attributes": { + "Section": "9. Email and Web Browser Protections", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "ses_identity_dkim_enabled" + ], + "cloudflare": [ + "zone_record_dkim_exists", + "zone_record_dmarc_exists", + "zone_record_spf_exists" + ], + "googleworkspace": [ + "gmail_groups_spoofing_protection_enabled", + "gmail_inbound_domain_spoofing_protection_enabled", + "gmail_unauthenticated_email_protection_enabled" + ], + "m365": [ + "defender_antiphishing_policy_configured", + "defender_domain_dkim_enabled" + ] + } + }, + { + "id": "9.6", + "name": "Block Unnecessary File Types", + "description": "Block unnecessary file types attempting to enter the enterprise's email gateway.", + "attributes": { + "Section": "9. Email and Web Browser Protections", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "googleworkspace": [ + "gmail_anomalous_attachment_protection_enabled", + "gmail_script_attachment_protection_enabled" + ], + "m365": [ + "defender_malware_policy_common_attachments_filter_enabled", + "defender_malware_policy_comprehensive_attachments_filter_applied" + ] + } + }, + { + "id": "9.7", + "name": "Deploy and Maintain Email Server Anti-Malware Protections", + "description": "Deploy and maintain email server anti-malware protections, such as attachment scanning and/or sandboxing.", + "attributes": { + "Section": "9. Email and Web Browser Protections", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "googleworkspace": [ + "gmail_encrypted_attachment_protection_enabled", + "gmail_enhanced_pre_delivery_scanning_enabled", + "gmail_external_image_scanning_enabled" + ], + "m365": [ + "defender_atp_safe_attachments_and_docs_configured", + "defender_malware_policy_notifications_internal_users_malware_enabled", + "defender_safe_attachments_policy_enabled" + ] + } + }, + { + "id": "10.1", + "name": "Deploy and Maintain Anti-Malware Software", + "description": "Deploy and maintain anti-malware software on all enterprise assets.", + "attributes": { + "Section": "10. Malware Defenses", + "Function": "Detect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ecs_instance_endpoint_protection_installed", + "securitycenter_all_assets_agent_installed" + ], + "aws": [ + "guardduty_ec2_malware_protection_enabled" + ], + "azure": [ + "defender_assessments_vm_endpoint_protection_installed", + "defender_ensure_defender_for_server_is_on" + ], + "m365": [ + "defender_atp_safe_attachments_and_docs_configured", + "defender_malware_policy_common_attachments_filter_enabled", + "defender_safe_attachments_policy_enabled", + "defender_zap_for_teams_enabled" + ] + } + }, + { + "id": "10.2", + "name": "Configure Automatic Anti-Malware Signature Updates", + "description": "Configure automatic updates for anti-malware signature files on all enterprise assets.", + "attributes": { + "Section": "10. Malware Defenses", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "10.3", + "name": "Disable Autorun and Autoplay for Removable Media", + "description": "Disable autorun and autoplay auto-execute functionality for removable media.", + "attributes": { + "Section": "10. Malware Defenses", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "10.4", + "name": "Configure Automatic Anti-Malware Scanning of Removable Media", + "description": "Configure anti-malware software to automatically scan removable media.", + "attributes": { + "Section": "10. Malware Defenses", + "Function": "Detect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "10.5", + "name": "Enable Anti-Exploitation Features", + "description": "Enable anti-exploitation features on enterprise assets and software, where possible, such as Microsoft® Data Execution Prevention (DEP), Windows® Defender Exploit Guard (WDEG), or Apple® System Integrity Protection (SIP) and Gatekeeper™.", + "attributes": { + "Section": "10. Malware Defenses", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "azure": [ + "vm_trusted_launch_enabled" + ], + "openstack": [ + "image_secure_boot_enabled" + ], + "oraclecloud": [ + "compute_instance_secure_boot_enabled" + ] + } + }, + { + "id": "10.6", + "name": "Centrally Manage Anti-Malware Software", + "description": "Centrally manage anti-malware software.", + "attributes": { + "Section": "10. Malware Defenses", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "securitycenter_advanced_or_enterprise_edition", + "securitycenter_all_assets_agent_installed" + ], + "aws": [ + "guardduty_centrally_managed", + "guardduty_delegated_admin_enabled_all_regions" + ], + "azure": [ + "aks_cluster_defender_enabled", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_storage_is_on" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_delegated_admin_enabled_all_regions", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "10.7", + "name": "Use Behavior-Based Anti-Malware Software", + "description": "Use behavior-based anti-malware software.", + "attributes": { + "Section": "10. Malware Defenses", + "Function": "Detect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "cloudtrail_threat_detection_enumeration", + "cloudtrail_threat_detection_llm_jacking", + "cloudtrail_threat_detection_privilege_escalation", + "guardduty_eks_runtime_monitoring_enabled", + "guardduty_is_enabled", + "guardduty_lambda_protection_enabled", + "guardduty_rds_protection_enabled", + "guardduty_s3_protection_enabled" + ], + "azure": [ + "defender_ensure_mcas_is_enabled", + "defender_ensure_wdatp_is_enabled" + ], + "m365": [ + "defender_atp_safe_attachments_and_docs_configured", + "defender_safe_attachments_policy_enabled", + "defender_zap_for_teams_enabled" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "11.1", + "name": "Establish and Maintain a Data Recovery Process", + "description": "Establish and maintain a documented data recovery process that includes detailed backup procedures. In the process, address the scope of data recovery activities, recovery prioritization, and the security of backup data. Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "11. Data Recovery", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "11.2", + "name": "Perform Automated Backups", + "description": "Perform automated backups of in-scope enterprise assets. Run backups weekly, or more frequently, based on the sensitivity of the data.", + "attributes": { + "Section": "11. Data Recovery", + "Function": "Recover", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "backup_plans_exist", + "backup_vaults_exist", + "dlm_ebs_snapshot_lifecycle_policy_exists", + "documentdb_cluster_backup_enabled", + "drs_job_exist", + "dynamodb_table_protected_by_backup_plan", + "dynamodb_tables_pitr_enabled", + "ec2_ebs_volume_protected_by_backup_plan", + "ec2_ebs_volume_snapshots_exists", + "efs_have_backup_enabled", + "elasticache_redis_cluster_backup_enabled", + "lightsail_instance_automated_snapshots", + "neptune_cluster_backup_enabled", + "rds_cluster_backtrack_enabled", + "rds_cluster_protected_by_backup_plan", + "rds_instance_backup_enabled", + "rds_instance_protected_by_backup_plan", + "redshift_cluster_automated_snapshot", + "s3_bucket_object_versioning" + ], + "azure": [ + "cosmosdb_account_backup_policy_continuous", + "mysql_flexible_server_geo_redundant_backup_enabled", + "postgresql_flexible_server_geo_redundant_backup_enabled", + "recovery_vault_has_protected_items", + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period" + ], + "gcp": [ + "cloudsql_instance_automated_backups", + "cloudstorage_bucket_soft_delete_enabled", + "cloudstorage_bucket_versioning_enabled" + ], + "linode": [ + "compute_instance_backups_enabled" + ], + "mongodbatlas": [ + "clusters_backup_enabled" + ], + "openstack": [ + "blockstorage_volume_backup_exists", + "objectstorage_container_versioning_enabled" + ], + "oraclecloud": [ + "objectstorage_bucket_versioning_enabled" + ] + }, + "config_requirements": [ + { + "Check": "drs_job_exist", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "11.3", + "name": "Protect Recovery Data", + "description": "Protect recovery data with equivalent controls to the original data. Reference encryption or data separation, based on requirements.", + "attributes": { + "Section": "11. Data Recovery", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "backup_recovery_point_encrypted", + "backup_vaults_encrypted", + "documentdb_cluster_public_snapshot", + "ec2_ebs_public_snapshot", + "ec2_ebs_snapshot_account_block_public_access", + "ec2_ebs_snapshots_encrypted", + "neptune_cluster_public_snapshot", + "neptune_cluster_snapshot_encrypted", + "rds_snapshots_encrypted", + "rds_snapshots_public_access", + "s3_bucket_no_mfa_delete", + "s3_bucket_object_lock" + ], + "azure": [ + "storage_ensure_file_shares_soft_delete_is_enabled", + "storage_ensure_soft_delete_is_enabled" + ], + "stackit": [ + "objectstorage_bucket_object_lock_enabled" + ] + } + }, + { + "id": "11.4", + "name": "Establish and Maintain an Isolated Instance of Recovery Data", + "description": "Establish and maintain an isolated instance of recovery data. Example implementations include, version controlling backup destinations through offline, cloud, or off-site systems or services.", + "attributes": { + "Section": "11. Data Recovery", + "Function": "Recover", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "s3_bucket_cross_region_replication" + ], + "azure": [ + "mysql_flexible_server_geo_redundant_backup_enabled", + "postgresql_flexible_server_geo_redundant_backup_enabled", + "storage_geo_redundant_enabled" + ] + } + }, + { + "id": "11.5", + "name": "Test Data Recovery", + "description": "Test backup recovery quarterly, or more frequently, for a sampling of in-scope enterprise assets.", + "attributes": { + "Section": "11. Data Recovery", + "Function": "Recover", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "12.1", + "name": "Ensure Network Infrastructure is Up-to-Date", + "description": "Ensure network infrastructure is kept up-to-date. Example implementations include running the latest stable release of software and/or using currently supported network as a service (Naas) offerings. Review software versions monthly, or more frequently, to verify software support.", + "attributes": { + "Section": "12. Network Infrastructure Management", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "12.2", + "name": "Establish and Maintain a Secure Network Architecture", + "description": "Design and maintain a secure network architecture. A secure network architecture must address segmentation, least privilege, and availability, at a minimum. Example implementations may include documentation, policy, and design components.", + "attributes": { + "Section": "12. Network Infrastructure Management", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "cs_kubernetes_private_cluster_enabled", + "ecs_instance_no_legacy_network" + ], + "aws": [ + "appstream_fleet_default_internet_access_disabled", + "autoscaling_group_launch_configuration_no_public_ip", + "awslambda_function_inside_vpc", + "dms_instance_no_public_access", + "ec2_instance_public_ip", + "ec2_launch_template_no_public_ip", + "ec2_transitgateway_auto_accept_vpc_attachments", + "ecs_service_no_assign_public_ip", + "ecs_task_set_no_assign_public_ip", + "eks_cluster_not_publicly_accessible", + "eks_cluster_private_nodes_enabled", + "elasticache_cluster_uses_public_subnet", + "elb_internet_facing", + "elbv2_internet_facing", + "emr_cluster_account_public_block_enabled", + "emr_cluster_master_nodes_no_public_ip", + "emr_cluster_publicly_accesible", + "kafka_cluster_is_public", + "lightsail_database_public", + "lightsail_instance_public", + "mq_broker_not_publicly_accessible", + "neptune_cluster_uses_public_subnet", + "opensearch_service_domains_not_publicly_accessible", + "rds_instance_inside_vpc", + "rds_instance_no_public_access", + "redshift_cluster_public_access", + "sagemaker_models_vpc_settings_configured", + "sagemaker_notebook_instance_vpc_settings_configured", + "sagemaker_notebook_instance_without_direct_internet_access_configured", + "sagemaker_training_jobs_vpc_settings_configured", + "vpc_endpoint_connections_trust_boundaries", + "vpc_endpoint_services_allowed_principals_trust_boundaries", + "vpc_peering_routing_tables_with_least_privilege", + "vpc_subnet_no_public_ip_by_default", + "vpc_subnet_separate_private_public" + ], + "azure": [ + "aisearch_service_not_publicly_accessible", + "aks_clusters_public_access_disabled", + "app_function_not_publicly_accessible", + "containerregistry_not_publicly_accessible", + "containerregistry_uses_private_link", + "cosmosdb_account_public_network_access_disabled", + "cosmosdb_account_use_private_endpoints", + "databricks_workspace_public_network_access_disabled", + "keyvault_access_only_through_private_endpoints", + "keyvault_private_endpoints", + "storage_account_public_network_access_disabled", + "storage_ensure_private_endpoints_in_storage_accounts" + ], + "gcp": [ + "cloudfunction_function_inside_vpc", + "cloudsql_instance_private_ip_assignment", + "cloudsql_instance_public_ip", + "cloudstorage_uses_vpc_service_controls", + "compute_instance_public_ip", + "compute_instance_single_network_interface", + "compute_network_default_in_use", + "compute_network_not_legacy" + ], + "mongodbatlas": [ + "projects_network_access_list_exposed_to_internet" + ], + "nhn": [ + "compute_instance_public_ip", + "network_vpc_subnet_has_external_router" + ], + "openstack": [ + "compute_instance_isolated_private_network" + ], + "oraclecloud": [ + "analytics_instance_access_restricted", + "database_autonomous_database_access_restricted", + "integration_instance_access_restricted", + "objectstorage_bucket_not_publicly_accessible" + ] + } + }, + { + "id": "12.3", + "name": "Securely Manage Network Infrastructure", + "description": "Securely manage network infrastructure. Example implementations include version-controlled Infrastructure-as-Code (IaC), and the use of secure network protocols, such as SSH and HTTPS.", + "attributes": { + "Section": "12. Network Infrastructure Management", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "gcp": [ + "dns_dnssec_disabled", + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "dns_rsasha1_in_use_to_zone_sign_in_dnssec" + ] + } + }, + { + "id": "12.4", + "name": "Establish and Maintain Architecture Diagram(s)", + "description": "Establish and maintain architecture diagram(s) and/or other network system documentation. Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "12. Network Infrastructure Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "12.5", + "name": "Centralize Network Authentication, Authorization, and Auditing (AAA)", + "description": "Centralize network AAA.", + "attributes": { + "Section": "12. Network Infrastructure Management", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "12.6", + "name": "Use of Secure Network Management and Communication Protocols", + "description": "Adopt secure network management protocols (e.g., 802.1X) and secure communication protocols (e.g., Wi-Fi Protected Access 2 (WPA2) Enterprise or more secure alternatives).", + "attributes": { + "Section": "12. Network Infrastructure Management", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "azure": [ + "app_minimum_tls_version_12", + "cosmosdb_account_minimum_tls_version", + "mysql_flexible_server_minimum_tls_version_12", + "sqlserver_recommended_minimal_tls_version", + "storage_ensure_minimum_tls_version_12", + "storage_smb_protocol_version_is_latest" + ], + "cloudflare": [ + "zone_automatic_https_rewrites_enabled", + "zone_hsts_enabled", + "zone_https_redirect_enabled", + "zone_min_tls_version_secure", + "zone_ssl_strict", + "zone_tls_1_3_enabled", + "zone_universal_ssl_enabled" + ], + "kubernetes": [ + "apiserver_client_ca_file_set", + "controllermanager_root_ca_file_set", + "controllermanager_rotate_kubelet_server_cert", + "etcd_client_cert_auth", + "etcd_peer_client_cert_auth", + "etcd_unique_ca", + "kubelet_client_ca_file_set", + "kubelet_rotate_certificates" + ] + } + }, + { + "id": "12.7", + "name": "Ensure Remote Devices Utilize a VPN and are Connecting to an Enterprise's AAA Infrastructure", + "description": "Require users to authenticate to enterprise-managed VPN and authentication services prior to accessing enterprise resources on end-user devices.", + "attributes": { + "Section": "12. Network Infrastructure Management", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "ec2_client_vpn_endpoint_connection_logging_enabled" + ] + } + }, + { + "id": "12.8", + "name": "Establish and Maintain Dedicated Computing Resources for All Administrative Work", + "description": "Establish and maintain dedicated computing resources, either physically or logically separated, for all administrative tasks or tasks requiring administrative access. The computing resources should be segmented from the enterprise's primary network and not be allowed internet access.", + "attributes": { + "Section": "12. Network Infrastructure Management", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "azure": [ + "network_bastion_host_exists", + "vm_jit_access_enabled" + ] + } + }, + { + "id": "13.1", + "name": "Centralize Security Event Alerting", + "description": "Centralize security event alerting across enterprise assets for log correlation and analysis. Best practice implementation requires the use of a SIEM, which includes vendor-defined event correlation alerts. A log analytics platform configured with security-relevant correlation alerts also satisfies this Safeguard.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Detect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "securitycenter_notification_enabled_high_risk", + "sls_cloud_firewall_changes_alert_enabled", + "sls_customer_created_cmk_changes_alert_enabled", + "sls_management_console_authentication_failures_alert_enabled", + "sls_management_console_signin_without_mfa_alert_enabled", + "sls_oss_bucket_policy_changes_alert_enabled", + "sls_oss_permission_changes_alert_enabled", + "sls_ram_role_changes_alert_enabled", + "sls_rds_instance_configuration_changes_alert_enabled", + "sls_root_account_usage_alert_enabled", + "sls_security_group_changes_alert_enabled", + "sls_unauthorized_api_calls_alert_enabled", + "sls_vpc_changes_alert_enabled", + "sls_vpc_network_route_changes_alert_enabled" + ], + "aws": [ + "cloudwatch_alarm_actions_alarm_state_configured", + "cloudwatch_alarm_actions_enabled", + "securityhub_delegated_admin_enabled_all_regions", + "securityhub_enabled" + ], + "azure": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_attack_path_notifications_properly_configured", + "defender_ensure_notify_alerts_severity_is_high", + "defender_ensure_notify_emails_to_owners", + "monitor_alert_create_update_security_solution" + ], + "googleworkspace": [ + "rules_admin_privilege_granted_alert_configured", + "rules_gmail_employee_spoofing_alert_configured", + "rules_government_backed_attacks_alert_configured", + "rules_leaked_password_alert_configured", + "rules_password_changed_alert_configured", + "rules_suspicious_activity_suspension_alert_configured", + "rules_suspicious_login_alert_configured", + "rules_suspicious_programmatic_login_alert_configured" + ], + "oraclecloud": [ + "events_notification_topic_and_subscription_exists", + "events_rule_cloudguard_problems", + "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" + ] + }, + "config_requirements": [ + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "13.2", + "name": "Deploy a Host-Based Intrusion Detection Solution", + "description": "Deploy a host-based intrusion detection solution on enterprise assets, where appropriate and/or supported.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Detect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "securitycenter_all_assets_agent_installed", + "ecs_instance_endpoint_protection_installed" + ], + "azure": [ + "defender_ensure_defender_for_server_is_on", + "defender_ensure_wdatp_is_enabled" + ] + } + }, + { + "id": "13.3", + "name": "Deploy a Network Intrusion Detection Solution", + "description": "Deploy a network intrusion detection solution on enterprise assets, where appropriate. Example implementations include the use of a Network Intrusion Detection System (NIDS) or equivalent cloud service provider (CSP) service.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Detect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "guardduty_eks_audit_log_enabled", + "guardduty_eks_runtime_monitoring_enabled", + "guardduty_is_enabled" + ], + "azure": [ + "defender_ensure_defender_for_dns_is_on" + ], + "oraclecloud": [ + "cloudguard_enabled" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "13.4", + "name": "Perform Traffic Filtering Between Network Segments", + "description": "Perform traffic filtering between network segments, where appropriate.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "cs_kubernetes_network_policy_enabled" + ], + "aws": [ + "ec2_networkacl_allow_ingress_any_port", + "ec2_networkacl_allow_ingress_tcp_port_22", + "ec2_networkacl_allow_ingress_tcp_port_3389", + "networkfirewall_in_all_vpc", + "vpc_peering_routing_tables_with_least_privilege" + ], + "azure": [ + "network_http_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_subnet_nsg_associated", + "network_udp_internet_access_restricted" + ], + "gcp": [ + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed" + ], + "linode": [ + "networking_firewall_default_inbound_policy_drop", + "networking_firewall_default_outbound_policy_drop", + "networking_firewall_inbound_rules_configured", + "networking_firewall_outbound_rules_configured" + ], + "mongodbatlas": [ + "projects_network_access_list_exposed_to_internet" + ], + "openstack": [ + "networking_security_group_allows_all_ingress_from_internet", + "networking_security_group_allows_rdp_from_internet", + "networking_security_group_allows_ssh_from_internet" + ], + "oraclecloud": [ + "network_default_security_list_restricts_traffic", + "network_security_group_ingress_from_internet_to_rdp_port", + "network_security_group_ingress_from_internet_to_ssh_port", + "network_security_list_ingress_from_internet_to_rdp_port", + "network_security_list_ingress_from_internet_to_ssh_port" + ], + "stackit": [ + "iaas_security_group_all_traffic_unrestricted", + "iaas_security_group_database_unrestricted", + "iaas_security_group_rdp_unrestricted", + "iaas_security_group_ssh_unrestricted" + ] + } + }, + { + "id": "13.5", + "name": "Manage Access Control for Remote Assets", + "description": "Manage access control for assets remotely connecting to enterprise resources. Determine amount of access to enterprise resources based on: up-to-date anti-malware software installed, configuration compliance with the enterprise's secure configuration process, and ensuring the operating system and applications are up-to-date.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "m365": [ + "entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required", + "entra_conditional_access_policy_mdm_compliant_device_required", + "entra_managed_device_required_for_authentication", + "intune_device_compliance_policy_unassigned_devices_not_compliant_by_default", + "sharepoint_onedrive_sync_restricted_unmanaged_devices" + ] + } + }, + { + "id": "13.6", + "name": "Collect Network Traffic Flow Logs", + "description": "Collect network traffic flow logs and/or network traffic to review and alert upon from network devices.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Detect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "vpc_flow_logs_enabled" + ], + "aws": [ + "vpc_flow_logs_enabled" + ], + "azure": [ + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "network_watcher_enabled" + ], + "gcp": [ + "compute_subnet_flow_logs_enabled" + ], + "oraclecloud": [ + "network_vcn_subnet_flow_logs_enabled" + ] + } + }, + { + "id": "13.7", + "name": "Deploy a Host-Based Intrusion Prevention Solution", + "description": "Deploy a host-based intrusion prevention solution on enterprise assets, where appropriate and/or supported. Example implementations include use of an Endpoint Detection and Response (EDR) client or host-based IPS agent.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "azure": [ + "defender_ensure_wdatp_is_enabled", + "defender_ensure_defender_for_server_is_on" + ] + } + }, + { + "id": "13.8", + "name": "Deploy a Network Intrusion Prevention Solution", + "description": "Deploy a network intrusion prevention solution, where appropriate. Example implementations include the use of a Network Intrusion Prevention System (NIPS) or equivalent CSP service.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "aws": [ + "networkfirewall_in_all_vpc", + "networkfirewall_policy_default_action_fragmented_packets", + "networkfirewall_policy_default_action_full_packets", + "networkfirewall_policy_rule_group_associated", + "shield_advanced_protection_in_associated_elastic_ips", + "shield_advanced_protection_in_classic_load_balancers", + "shield_advanced_protection_in_cloudfront_distributions", + "shield_advanced_protection_in_global_accelerators", + "shield_advanced_protection_in_internet_facing_load_balancers", + "shield_advanced_protection_in_route53_hosted_zones" + ], + "azure": [ + "network_vnet_ddos_protection_enabled" + ], + "cloudflare": [ + "zone_bot_fight_mode_enabled", + "zone_firewall_blocking_rules_configured", + "zone_rate_limiting_enabled", + "zone_waf_enabled", + "zone_waf_owasp_ruleset_enabled" + ], + "vercel": [ + "security_managed_rulesets_enabled", + "security_rate_limiting_configured", + "security_waf_enabled" + ] + } + }, + { + "id": "13.9", + "name": "Deploy Port-Level Access Control", + "description": "Deploy port-level access control. Port-level access control utilizes 802.1x, or similar network access control protocols, such as certificates, and may incorporate user and/or device authentication.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "13.10", + "name": "Perform Application Layer Filtering", + "description": "Perform application layer filtering. Example implementations include a filtering proxy, application layer firewall, or gateway.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "aws": [ + "apigateway_restapi_waf_acl_attached", + "cloudfront_distributions_using_waf", + "cognito_user_pool_waf_acl_attached", + "elbv2_waf_acl_attached", + "waf_global_rule_with_conditions", + "waf_global_rulegroup_not_empty", + "waf_global_webacl_with_rules", + "waf_regional_rule_with_conditions", + "waf_regional_rulegroup_not_empty", + "waf_regional_webacl_with_rules", + "wafv2_webacl_with_rules" + ], + "cloudflare": [ + "dns_record_proxied", + "zone_bot_fight_mode_enabled", + "zone_browser_integrity_check_enabled", + "zone_firewall_blocking_rules_configured", + "zone_rate_limiting_enabled", + "zone_waf_enabled", + "zone_waf_owasp_ruleset_enabled" + ], + "vercel": [ + "security_custom_rules_configured", + "security_ip_blocking_rules_configured", + "security_managed_rulesets_enabled", + "security_rate_limiting_configured", + "security_waf_enabled" + ] + } + }, + { + "id": "13.11", + "name": "Tune Security Event Alerting Thresholds", + "description": "Tune security event alerting thresholds monthly, or more frequently.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Detect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "14.1", + "name": "Establish and Maintain a Security Awareness Program", + "description": "Establish and maintain a security awareness program. The purpose of a security awareness program is to educate the enterprise's workforce on how to interact with enterprise assets and data in a secure manner. Conduct training at hire and, at a minimum, annually. Review and update content annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "14. Security Awareness and Skills Training", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "14.2", + "name": "Train Workforce Members to Recognize Social Engineering Attacks", + "description": "Train workforce members to recognize social engineering attacks, such as phishing, business email compromise (BEC), pretexting, and tailgating.", + "attributes": { + "Section": "14. Security Awareness and Skills Training", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "14.3", + "name": "Train Workforce Members on Authentication Best Practices", + "description": "Train workforce members on authentication best practices. Example topics include MFA, password composition, and credential management.", + "attributes": { + "Section": "14. Security Awareness and Skills Training", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "14.4", + "name": "Train Workforce on Data Handling Best Practices", + "description": "Train workforce members on how to identify and properly store, transfer, archive, and destroy sensitive data. This also includes training workforce members on clear screen and desk best practices, such as locking their screen when they step away from their enterprise asset, erasing physical and virtual whiteboards at the end of meetings, and storing data and assets securely.", + "attributes": { + "Section": "14. Security Awareness and Skills Training", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "14.5", + "name": "Train Workforce Members on Causes of Unintentional Data Exposure", + "description": "Train workforce members to be aware of causes for unintentional data exposure. Example topics include mis-delivery of sensitive data, losing a portable end-user device, or publishing data to unintended audiences.", + "attributes": { + "Section": "14. Security Awareness and Skills Training", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "14.6", + "name": "Train Workforce Members on Recognizing and Reporting Security Incidents", + "description": "Train workforce members to be able to recognize a potential incident and be able to report such an incident.", + "attributes": { + "Section": "14. Security Awareness and Skills Training", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "14.7", + "name": "Train Workforce on How to Identify and Report if Their Enterprise Assets are Missing Security Updates", + "description": "Train workforce to understand how to verify and report out-of-date software patches or any failures in automated processes and tools. Part of this training should include notifying IT personnel of any failures in automated processes and tools.", + "attributes": { + "Section": "14. Security Awareness and Skills Training", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "14.8", + "name": "Train Workforce on the Dangers of Connecting to and Transmitting Enterprise Data Over Insecure Networks", + "description": "Train workforce members on the dangers of connecting to, and transmitting data over, insecure networks for enterprise activities. If the enterprise has remote workers, training must include guidance to ensure that all users securely configure their home network infrastructure.", + "attributes": { + "Section": "14. Security Awareness and Skills Training", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "14.9", + "name": "Conduct Role-Specific Security Awareness and Skills Training", + "description": "Conduct role-specific security awareness and skills training. Example implementations include secure system administration courses for IT professionals, OWASP® Top 10 vulnerability awareness and prevention training for web application developers, and advanced social engineering awareness training for high-profile roles.", + "attributes": { + "Section": "14. Security Awareness and Skills Training", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "15.1", + "name": "Establish and Maintain an Inventory of Service Providers", + "description": "Establish and maintain an inventory of service providers. The inventory is to list all known service providers, include classification(s), and designate an enterprise contact for each service provider. Review and update the inventory annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "15. Service Provider Management", + "Function": "Identify", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "15.2", + "name": "Establish and Maintain a Service Provider Management Policy", + "description": "Establish and maintain a service provider management policy. Ensure the policy addresses the classification, inventory, assessment, monitoring, and decommissioning of service providers. Review and update the policy annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "15. Service Provider Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "15.3", + "name": "Classify Service Providers", + "description": "Classify service providers. Classification consideration may include one or more characteristics, such as data sensitivity, data volume, availability requirements, applicable regulations, inherent risk, and mitigated risk. Update and review classifications annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "15. Service Provider Management", + "Function": "Govern", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "15.4", + "name": "Ensure Service Provider Contracts Include Security Requirements", + "description": "Ensure service provider contracts include security requirements. Example requirements may include minimum security program requirements, security incident and/or data breach notification and response, data encryption requirements, and data disposal commitments. These security requirements must be consistent with the enterprise's service provider management policy. Review service provider contracts annually to ensure contracts are not missing security requirements.", + "attributes": { + "Section": "15. Service Provider Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "15.5", + "name": "Assess Service Providers", + "description": "Assess service providers consistent with the enterprise's service provider management policy. Assessment scope may vary based on classification(s), and may include review of standardized assessment reports, such as Service Organization Control 2 (SOC 2) and Payment Card Industry (PCI) Attestation of Compliance (AoC), customized questionnaires, or other appropriately rigorous processes. Reassess service providers annually, at a minimum, or with new and renewed contracts.", + "attributes": { + "Section": "15. Service Provider Management", + "Function": "Govern", + "AssetType": "Users", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "15.6", + "name": "Monitor Service Providers", + "description": "Monitor service providers consistent with the enterprise's service provider management policy. Monitoring may include periodic reassessment of service provider compliance, monitoring service provider release notes, and dark web monitoring.", + "attributes": { + "Section": "15. Service Provider Management", + "Function": "Govern", + "AssetType": "Data", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "15.7", + "name": "Securely Decommission Service Providers", + "description": "Securely decommission service providers. Example considerations include user and service account deactivation, termination of data flows, and secure disposal of enterprise data within service provider systems.", + "attributes": { + "Section": "15. Service Provider Management", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "16.1", + "name": "Establish and Maintain a Secure Application Development Process", + "description": "Establish and maintain a secure application development process. In the process, address such items as: secure application design standards, secure coding practices, developer training, vulnerability management, security of third-party code, and application security testing procedures. Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "github": [ + "repository_default_branch_deletion_disabled", + "repository_default_branch_disallows_force_push", + "repository_default_branch_dismisses_stale_reviews", + "repository_default_branch_protection_applies_to_admins", + "repository_default_branch_protection_enabled", + "repository_default_branch_requires_codeowners_review", + "repository_default_branch_requires_conversation_resolution", + "repository_default_branch_requires_linear_history", + "repository_default_branch_requires_multiple_approvals", + "repository_default_branch_requires_signed_commits", + "repository_default_branch_status_checks_required", + "repository_has_codeowners_file" + ] + } + }, + { + "id": "16.2", + "name": "Establish and Maintain a Process to Accept and Address Software Vulnerabilities", + "description": "Establish and maintain a process to accept and address reports of software vulnerabilities, including providing a means for external entities to report. The process is to include such items as: a vulnerability handling policy that identifies reporting process, responsible party for handling vulnerability reports, and a process for intake, assignment, remediation, and remediation testing. As part of the process, use a vulnerability tracking system that includes severity ratings and metrics for measuring timing for identification, analysis, and remediation of vulnerabilities. Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard. Third-party application developers need to consider this an externally-facing policy that helps to set expectations for outside stakeholders.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "github": [ + "repository_public_has_securitymd_file" + ] + } + }, + { + "id": "16.3", + "name": "Perform Root Cause Analysis on Security Vulnerabilities", + "description": "Perform root cause analysis on security vulnerabilities. When reviewing vulnerabilities, root cause analysis is the task of evaluating underlying issues that create vulnerabilities in code, and allows development teams to move beyond just fixing individual vulnerabilities as they arise.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Detect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "16.4", + "name": "Establish and Manage an Inventory of Third-Party Software Components", + "description": "Establish and manage an updated inventory of third-party components used in development, often referred to as a \"bill of materials,\" as well as components slated for future use. This inventory is to include any risks that each third-party component could pose. Evaluate the list at least monthly to identify any changes or updates to these components, and validate that the component is still supported.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Identify", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "github": [ + "repository_dependency_scanning_enabled" + ] + } + }, + { + "id": "16.5", + "name": "Use Up-to-Date and Trusted Third-Party Software Components", + "description": "Use up-to-date and trusted third-party software components. When possible, choose established and proven frameworks and libraries that provide adequate security. Acquire these components from trusted sources or evaluate the software for vulnerabilities before use.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "codeartifact_packages_external_public_publishing_disabled" + ], + "github": [ + "repository_dependency_scanning_enabled" + ] + } + }, + { + "id": "16.6", + "name": "Establish and Maintain a Severity Rating System and Process for Application Vulnerabilities", + "description": "Establish and maintain a severity rating system and process for application vulnerabilities that facilitates prioritizing the order in which discovered vulnerabilities are fixed. This process includes setting a minimum level of security acceptability for releasing code or applications. Severity ratings bring a systematic way of triaging vulnerabilities that improves risk management and helps ensure the most severe bugs are fixed first. Review and update the system and process annually.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "16.7", + "name": "Use Standard Hardening Configuration Templates for Application Infrastructure", + "description": "Use standard, industry-recommended hardening configuration templates for application infrastructure components. This includes underlying servers, databases, and web servers, and applies to cloud containers, Platform as a Service (PaaS) components, and SaaS components. Do not allow in-house developed software to weaken configuration hardening.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "azure": [ + "app_ensure_using_http20", + "app_ftp_deployment_disabled", + "app_minimum_tls_version_12" + ], + "gcp": [ + "cloudsql_instance_mysql_local_infile_flag", + "cloudsql_instance_mysql_skip_show_database_flag", + "cloudsql_instance_sqlserver_contained_database_authentication_flag", + "cloudsql_instance_sqlserver_cross_db_ownership_chaining_flag", + "cloudsql_instance_sqlserver_external_scripts_enabled_flag", + "cloudsql_instance_sqlserver_remote_access_flag", + "compute_instance_shielded_vm_enabled", + "gke_cluster_no_default_service_account" + ], + "kubernetes": [ + "apiserver_always_pull_images_plugin", + "apiserver_deny_service_external_ips", + "apiserver_event_rate_limit", + "apiserver_namespace_lifecycle_plugin", + "apiserver_no_always_admit_plugin", + "apiserver_node_restriction_plugin", + "apiserver_security_context_deny_plugin", + "core_image_tag_fixed", + "core_minimize_admission_hostport_containers", + "core_minimize_admission_windows_hostprocess_containers", + "core_minimize_allowPrivilegeEscalation_containers", + "core_minimize_containers_added_capabilities", + "core_minimize_containers_capabilities_assigned", + "core_minimize_hostIPC_containers", + "core_minimize_hostNetwork_containers", + "core_minimize_hostPID_containers", + "core_minimize_net_raw_capability_admission", + "core_minimize_privileged_containers", + "core_minimize_root_containers_admission", + "core_seccomp_profile_docker_default" + ], + "vercel": [ + "project_auto_expose_system_env_disabled", + "project_directory_listing_disabled", + "project_git_fork_protection_enabled" + ] + } + }, + { + "id": "16.8", + "name": "Separate Production and Non-Production Systems", + "description": "Maintain separate environments for production and non-production systems.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "vercel": [ + "deployment_production_uses_stable_target", + "project_deployment_protection_enabled", + "project_environment_no_overly_broad_target", + "project_environment_production_vars_not_in_preview" + ] + } + }, + { + "id": "16.9", + "name": "Train Developers in Application Security Concepts and Secure Coding", + "description": "Ensure that all software development personnel receive training in writing secure code for their specific development environment and responsibilities. Training can include general security principles and application security standard practices. Conduct training at least annually and design in a way to promote security within the development team, and build a culture of security among the developers.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "16.10", + "name": "Apply Secure Design Principles in Application Architectures", + "description": "Apply secure design principles in application architectures. Secure design principles include the concept of least privilege and enforcing mediation to validate every operation that the user makes, promoting the concept of \"never trust user input.\" Examples include ensuring that explicit error checking is performed and documented for all input, including for size, data type, and acceptable ranges or formats. Secure design also means minimizing the application infrastructure attack surface, such as turning off unprotected ports and services, removing unnecessary programs and files, and renaming or removing default accounts.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "apigateway_restapi_authorizers_enabled", + "apigateway_restapi_public_with_authorizer", + "apigatewayv2_api_authorizers_enabled", + "appsync_graphql_api_no_api_key_authentication", + "cognito_user_pool_client_prevent_user_existence_errors", + "ecs_task_definitions_containers_readonly_access", + "ecs_task_definitions_host_namespace_not_shared", + "ecs_task_definitions_host_networking_mode_users", + "ecs_task_definitions_no_privileged_containers", + "elb_desync_mitigation_mode", + "elbv2_alb_drop_invalid_header_fields_enabled", + "elbv2_desync_mitigation_mode", + "sagemaker_notebook_instance_root_access_disabled" + ] + } + }, + { + "id": "16.11", + "name": "Leverage Vetted Modules or Services for Application Security Components", + "description": "Leverage vetted modules or services for application security components, such as identity management, encryption, auditing, and logging. Using platform features in critical security functions will reduce developers' workload and minimize the likelihood of design or implementation errors. Modern operating systems provide effective mechanisms for identification, authentication, and authorization and make those mechanisms available to applications. Use only standardized, currently accepted, and extensively reviewed encryption algorithms. Operating systems also provide mechanisms to create and maintain secure audit logs.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "secretsmanager_automatic_rotation_enabled", + "secretsmanager_secret_rotated_periodically" + ], + "azure": [ + "app_ensure_auth_is_set_up", + "app_function_access_keys_configured", + "app_function_identity_is_configured", + "app_register_with_identity" + ], + "vercel": [ + "team_directory_sync_enabled", + "team_saml_sso_enabled" + ] + } + }, + { + "id": "16.12", + "name": "Implement Code-Level Security Checks", + "description": "Apply static and dynamic analysis tools within the application life cycle to verify that secure coding practices are being followed.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "aws": [ + "awslambda_function_no_secrets_in_code", + "inspector2_is_enabled" + ], + "github": [ + "githubactions_workflow_security_scan", + "repository_secret_scanning_enabled" + ] + } + }, + { + "id": "16.13", + "name": "Conduct Application Penetration Testing", + "description": "Conduct application penetration testing. For critical applications, authenticated penetration testing is better suited to finding business logic vulnerabilities than code scanning and automated security testing. Penetration testing relies on the skill of the tester to manually manipulate an application as an authenticated and unauthenticated user.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Detect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "16.14", + "name": "Conduct Threat Modeling", + "description": "Conduct threat modeling. Threat modeling is the process of identifying and addressing application security design flaws within a design, before code is created. It is conducted through specially trained individuals who evaluate the application design and gauge security risks for each entry point and access level. The goal is to map out the application, architecture, and infrastructure in a structured way to understand its weaknesses.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "17.1", + "name": "Designate Personnel to Manage Incident Handling", + "description": "Designate one key person, and at least one backup, who will manage the enterprise's incident handling process. Management personnel are responsible for the coordination and documentation of incident response and recovery efforts and can consist of employees internal to the enterprise, service providers, or a hybrid approach. If using a service provider, designate at least one person internal to the enterprise to oversee any third-party work. Review annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "17. Incident Response Management", + "Function": "Respond", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "17.2", + "name": "Establish and Maintain Contact Information for Reporting Security Incidents", + "description": "Establish and maintain contact information for parties that need to be informed of security incidents. Contacts may include internal staff, service providers, law enforcement, cyber insurance providers, relevant government agencies, Information Sharing and Analysis Center (ISAC) partners, or other stakeholders. Verify contacts annually to ensure that information is up-to-date.", + "attributes": { + "Section": "17. Incident Response Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "account_maintain_current_contact_details", + "account_maintain_different_contact_details_to_security_billing_and_operations", + "account_security_contact_information_is_registered" + ], + "gcp": [ + "iam_organization_essential_contacts_configured" + ], + "mongodbatlas": [ + "organizations_security_contact_defined" + ] + } + }, + { + "id": "17.3", + "name": "Establish and Maintain an Enterprise Process for Reporting Incidents", + "description": "Establish and maintain an documented enterprise process for the workforce to report security incidents. The process includes reporting timeframe, personnel to report to, mechanism for reporting, and the minimum information to be reported. Ensure the process is publicly available to all of the workforce. Review annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "17. Incident Response Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "17.4", + "name": "Establish and Maintain an Incident Response Process", + "description": "Establish and maintain a documented incident response process that addresses roles and responsibilities, compliance requirements, and a communication plan. Review annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "17. Incident Response Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "ssmincidents_enabled_with_plans" + ] + } + }, + { + "id": "17.5", + "name": "Assign Key Roles and Responsibilities", + "description": "Assign key roles and responsibilities for incident response, including staff from legal, IT, information security, facilities, public relations, human resources, incident responders, analysts, and relevant third parties. Review annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "17. Incident Response Management", + "Function": "Respond", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "17.6", + "name": "Define Mechanisms for Communicating During Incident Response", + "description": "Determine which primary and secondary mechanisms will be used to communicate and report during a security incident. Mechanisms can include phone calls, emails, secure chat, or notification letters. Keep in mind that certain mechanisms, such as emails, can be affected during a security incident. Review annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "17. Incident Response Management", + "Function": "Respond", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "17.7", + "name": "Conduct Routine Incident Response Exercises", + "description": "Plan and conduct routine incident response exercises and scenarios for key personnel involved in the incident response process to prepare for responding to real-world incidents. Exercises need to test communication channels, decision making, and workflows. Conduct testing on an annual basis, at a minimum.", + "attributes": { + "Section": "17. Incident Response Management", + "Function": "Recover", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "17.8", + "name": "Conduct Post-Incident Reviews", + "description": "Conduct post-incident reviews. Post-incident reviews help prevent incident recurrence through identifying lessons learned and follow-up action.", + "attributes": { + "Section": "17. Incident Response Management", + "Function": "Recover", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "17.9", + "name": "Establish and Maintain Security Incident Thresholds", + "description": "Establish and maintain security incident thresholds, including, at a minimum, differentiating between an incident and an event. Examples can include: abnormal activity, security vulnerability, security weakness, data breach, privacy incident, etc. Review annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "17. Incident Response Management", + "Function": "Recover", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "18.1", + "name": "Establish and Maintain a Penetration Testing Program", + "description": "Establish and maintain a penetration testing program appropriate to the size, complexity, industry, and maturity of the enterprise. Penetration testing program characteristics include scope, such as network, web application, Application Programming Interface (API), hosted services, and physical premise controls; frequency; limitations, such as acceptable hours, and excluded attack types; point of contact information; remediation, such as how findings will be routed internally; and retrospective requirements.", + "attributes": { + "Section": "18. Penetration Testing", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "18.2", + "name": "Perform Periodic External Penetration Tests", + "description": "Perform periodic external penetration tests based on program requirements, no less than annually. External penetration testing must include enterprise and environmental reconnaissance to detect exploitable information. Penetration testing requires specialized skills and experience and must be conducted through a qualified party. The testing may be clear box or opaque box.", + "attributes": { + "Section": "18. Penetration Testing", + "Function": "Detect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "18.3", + "name": "Remediate Penetration Test Findings", + "description": "Remediate penetration test findings based on the enterprise's documented vulnerability remediation process. This should include determining a timeline and level of effort based on the impact and prioritization of each identified finding.", + "attributes": { + "Section": "18. Penetration Testing", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "18.4", + "name": "Validate Security Measures", + "description": "Validate security measures after each penetration test. If deemed necessary, modify rulesets and capabilities to detect the techniques used during testing.", + "attributes": { + "Section": "18. Penetration Testing", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "18.5", + "name": "Perform Periodic Internal Penetration Tests", + "description": "Perform periodic internal penetration tests based on program requirements, no less than annually. The testing may be clear box or opaque box.", + "attributes": { + "Section": "18. Penetration Testing", + "Function": "Detect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + } + ] +} diff --git a/prowler/compliance/csa_ccm_4.0.json b/prowler/compliance/csa_ccm_4.0.json index b6bb382cda..1cb7428e51 100644 --- a/prowler/compliance/csa_ccm_4.0.json +++ b/prowler/compliance/csa_ccm_4.0.json @@ -229,7 +229,16 @@ "oraclecloud": [ "cloudguard_enabled" ] - } + }, + "config_requirements": [ + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "A&A-04", @@ -334,7 +343,23 @@ "oraclecloud": [ "cloudguard_enabled" ] - } + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "AIS-04", @@ -978,7 +1003,16 @@ "defender_ensure_defender_for_server_is_on", "vm_backup_enabled" ] - } + }, + "config_requirements": [ + { + "Check": "drs_job_exist", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "BCR-11", @@ -1416,7 +1450,30 @@ "events_rule_security_list_changes", "events_rule_vcn_changes" ] - } + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "CEK-03", @@ -1659,7 +1716,18 @@ "filestorage_file_system_encrypted_with_cmk", "objectstorage_bucket_encrypted_with_cmk" ] - } + }, + "config_requirements": [ + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "Provider": "azure", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ] + } + ] }, { "id": "CEK-04", @@ -1802,7 +1870,29 @@ "dns_rsasha1_in_use_to_key_sign_in_dnssec", "dns_rsasha1_in_use_to_zone_sign_in_dnssec" ] - } + }, + "config_requirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "Provider": "aws", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + }, + { + "Check": "sqlserver_recommended_minimal_tls_version", + "Provider": "azure", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } + ] }, { "id": "CEK-08", @@ -2345,7 +2435,16 @@ "alibabacloud": [ "securitycenter_all_assets_agent_installed" ] - } + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "DSP-02", @@ -2583,7 +2682,16 @@ "alibabacloud": [ "securitycenter_all_assets_agent_installed" ] - } + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "DSP-04", @@ -2997,7 +3105,19 @@ "oraclecloud": [ "compute_instance_in_transit_encryption_enabled" ] - } + }, + "config_requirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "Provider": "azure", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } + ] }, { "id": "DSP-16", @@ -3403,7 +3523,23 @@ "oraclecloud": [ "cloudguard_enabled" ] - } + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "IAM-02", @@ -6255,7 +6391,16 @@ "cloudguard_enabled", "events_rule_cloudguard_problems" ] - } + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "LOG-02", @@ -6558,7 +6703,23 @@ "events_notification_topic_and_subscription_exists", "events_rule_local_user_authentication" ] - } + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "LOG-04", @@ -7602,7 +7763,16 @@ "events_rule_cloudguard_problems", "events_notification_topic_and_subscription_exists" ] - } + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "SEF-03", @@ -7880,7 +8050,23 @@ "oraclecloud": [ "cloudguard_enabled" ] - } + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "SEF-08", @@ -8461,7 +8647,16 @@ "oraclecloud": [ "cloudguard_enabled" ] - } + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "TVM-05", @@ -8729,7 +8924,16 @@ "oraclecloud": [ "cloudguard_enabled" ] - } + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "UEM-08", diff --git a/prowler/compliance/dora_2022_2554.json b/prowler/compliance/dora_2022_2554.json index e4b5fd69df..3b828059da 100644 --- a/prowler/compliance/dora_2022_2554.json +++ b/prowler/compliance/dora_2022_2554.json @@ -215,7 +215,37 @@ "securitycenter_vulnerability_scan_enabled", "actiontrail_multi_region_enabled" ] - } + }, + "config_requirements": [ + { + "Check": "accessanalyzer_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_delegated_admin_enabled_all_regions", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "DORA-Art7", @@ -299,7 +329,38 @@ "ecs_unattached_disk_encrypted", "ecs_instance_no_legacy_network" ] - } + }, + "config_requirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "Provider": "aws", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + }, + { + "Check": "sqlserver_recommended_minimal_tls_version", + "Provider": "azure", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + }, + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "Provider": "azure", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ] + } + ] }, { "id": "DORA-Art8", @@ -344,7 +405,16 @@ "securitycenter_all_assets_agent_installed", "ram_user_console_access_unused" ] - } + }, + "config_requirements": [ + { + "Check": "accessanalyzer_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "DORA-Art9", @@ -580,7 +650,23 @@ "ecs_instance_endpoint_protection_installed", "cs_kubernetes_cloudmonitor_enabled" ] - } + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "DORA-Art11", @@ -732,7 +818,16 @@ "securitycenter_all_assets_agent_installed", "ecs_instance_latest_os_patches_applied" ] - } + }, + "config_requirements": [ + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "DORA-Art14", @@ -901,7 +996,23 @@ "securitycenter_notification_enabled_high_risk", "securitycenter_vulnerability_scan_enabled" ] - } + }, + "config_requirements": [ + { + "Check": "guardduty_delegated_admin_enabled_all_regions", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "DORA-Art19", @@ -1017,7 +1128,16 @@ "cs_kubernetes_cluster_check_recent", "cs_kubernetes_cluster_check_weekly" ] - } + }, + "config_requirements": [ + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "DORA-Art25", @@ -1079,7 +1199,23 @@ "ecs_instance_latest_os_patches_applied", "ecs_instance_no_legacy_network" ] - } + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "DORA-Art28", @@ -1144,7 +1280,16 @@ "oss_bucket_not_publicly_accessible", "actiontrail_oss_bucket_not_publicly_accessible" ] - } + }, + "config_requirements": [ + { + "Check": "accessanalyzer_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "DORA-Art30", @@ -1200,7 +1345,16 @@ "ram_policy_attached_only_to_group_or_roles", "ram_no_root_access_key" ] - } + }, + "config_requirements": [ + { + "Check": "accessanalyzer_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] }, { "id": "DORA-Art45", @@ -1245,7 +1399,23 @@ "actiontrail_multi_region_enabled", "sls_logstore_retention_period" ] - } + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] } ] } diff --git a/prowler/compliance/gcp/ccc_gcp.json b/prowler/compliance/gcp/ccc_gcp.json index 67a75841ef..520b6b534a 100644 --- a/prowler/compliance/gcp/ccc_gcp.json +++ b/prowler/compliance/gcp/ccc_gcp.json @@ -924,6 +924,14 @@ "cloudsql_instance_automated_backups", "cloudstorage_bucket_log_retention_policy_lock", "cloudstorage_bucket_sufficient_retention_period" + ], + "ConfigRequirements": [ + { + "Check": "cloudstorage_bucket_sufficient_retention_period", + "ConfigKey": "storage_min_retention_days", + "Operator": "gte", + "Value": 30 + } ] }, { @@ -5841,6 +5849,20 @@ "Checks": [ "iam_sa_user_managed_key_unused", "iam_service_account_unused" + ], + "ConfigRequirements": [ + { + "Check": "iam_sa_user_managed_key_unused", + "ConfigKey": "max_unused_account_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_service_account_unused", + "ConfigKey": "max_unused_account_days", + "Operator": "lte", + "Value": 90 + } ] }, { diff --git a/prowler/compliance/gcp/cis_5.0_gcp.json b/prowler/compliance/gcp/cis_5.0_gcp.json new file mode 100644 index 0000000000..edc962ff0c --- /dev/null +++ b/prowler/compliance/gcp/cis_5.0_gcp.json @@ -0,0 +1,2097 @@ +{ + "Framework": "CIS", + "Name": "CIS Google Cloud Platform Foundation Benchmark v5.0.0", + "Version": "5.0", + "Provider": "GCP", + "Description": "The CIS Google Cloud Platform Foundations Benchmark provides prescriptive guidance for configuring security options for a subset of GCP with an emphasis on foundational, testable, and architecture agnostic settings.", + "Requirements": [ + { + "Id": "1.1.1", + "Description": "Ensure Super Admin Email Address Is Not Tied To A Single User", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "It is recommended that the super admin role is assigned to a dedicated email address (e.g., gcp-superadmin@company.com) rather than to individual user email addresses (e.g., john.doe@company.com). The dedicated super admin email should be a separate user account used exclusively for super admin level administrative tasks. When the GCP organization resource is created, existing super administrator accounts from Google Workspace or Cloud Identity automatically gain access to manage the organization. The super admin role has unrestricted access to all GCP organization resources and settings. Using individual user email addresses for super admin access creates security and operational risks.", + "RationaleStatement": "Using a dedicated email address for super admin accounts rather than individual user emails significantly reduces the risk of phishing attacks, as individual user accounts receive daily business emails and are high-value phishing targets, while a dedicated super admin email address not used for daily communications has minimal exposure to credential compromise. Dedicated super admin accounts can enforce stronger authentication requirements such as hardware security keys and shorter session timeouts without impacting day-to-day productivity. When employees leave the organization or change roles, super admin access remains continuous through the dedicated account rather than being tied to departing personnel. Separating super admin access from daily user activities prevents accidental misuse of elevated privileges and provides clear audit separation, supporting segregation of duties.", + "ImpactStatement": "Creating a new dedicated super admin account requires coordination with Google Workspace or Cloud Identity administrators. Existing super admin accounts tied to individual users should be replaced with the dedicated account, and individual user accounts should be removed from the super admin role once migration is complete.", + "AuditProcedure": "**From Google Cloud Admin Console**\n\n1. Navigate to the Google Admin console at https://admin.google.com\n2. Go to `Account` --> `Admin roles` in the left navigation menu\n3. Click on `Super Admin` role\n4. Review the list of users assigned the Super Admin role\n5. Verify that the super admin role is assigned to a dedicated email address (e.g., gcp-superadmin@company.com) and that individual user email addresses are NOT assigned the Super Admin role directly.", + "RemediationProcedure": "**From Google Cloud Admin Console**\n\n1. Create a dedicated super admin user account: in the Google Admin console go to `Directory` -> `Users`, click `Add new user` and configure a dedicated address (e.g., gcp-superadmin@company.com). Enforce 2-Step Verification with a hardware security key.\n2. Assign the Super Admin role to the dedicated account: go to `Account` --> `Admin roles`, click `Super Admin` role, `Assigned Admins` --> `Assign users`, select the dedicated account and click `Assign role`.\n3. Remove individual user accounts from the Super Admin role: for each individual user email address, select the user, click `Unassign role` and confirm the removal. Verify that only the dedicated super admin account remains assigned.", + "AdditionalInformation": "", + "References": "", + "SubSection": "1.1 Organization Resource", + "DefaultValue": "When you first sign up for Google Workspace or Cloud Identity, the person who completes the signup process automatically becomes the first super administrator, typically resulting in an individual user's email address being assigned the super admin role by default." + } + ] + }, + { + "Id": "1.1.2", + "Description": "Ensure Super Admin Account Is Not Used For Google Cloud Administration", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "It is recommended that super admin accounts are not granted any IAM roles in Google Cloud Platform and are used exclusively for identity management in Google Workspace or Cloud Identity. Google Cloud administration should be performed using separate regular user accounts that are granted appropriate GCP IAM roles such as Organization Administrator. The Super Admin role and GCP resource management are two separate privilege domains that should not be combined in a single account.", + "RationaleStatement": "Separating super admin accounts from GCP administration is critical because Super Admin privileges are irrevocable and can override almost any setting in both Workspace and GCP. If a super admin account is granted GCP permissions and compromised through phishing, an attacker gains full control over both the email domain and the entire cloud infrastructure. Super admin accounts not granted GCP IAM permissions cannot be used to directly manipulate cloud resources even if compromised, limiting blast radius to identity management functions only. This separation enforces least privilege and reduces the attack surface by ensuring the most powerful account type is isolated from daily cloud operations.", + "ImpactStatement": "Organizations must create separate regular user accounts in Workspace/Cloud Identity for GCP administration. These accounts will be granted appropriate GCP IAM roles for cloud resource management. Existing super admin accounts that have been granted GCP IAM roles must have those permissions removed.", + "AuditProcedure": "**From Google Cloud Admin Console**\n\n1. In the Google Admin console (https://admin.google.com), go to `Account` -> `Admin roles`, click `Super Admin` role and note all user accounts assigned the Super Admin role.\n2. In the Google Cloud Console (https://console.cloud.google.com), go to `IAM & Admin` -> `IAM` at the organization level and review all IAM policy bindings.\n3. Verify that none of the super admin accounts appear in the IAM permissions list and that they have NO IAM roles granted at organization, folder, or project levels.", + "RemediationProcedure": "**From Google Cloud Admin Console**\n\n1. Create a separate regular user account for GCP administration in `Directory` -> `Users` (do NOT assign it the Super Admin role).\n2. Grant GCP IAM roles to that cloud admin account in the Cloud Console under `IAM & Admin` -> `IAM` (e.g., Organization Administrator).\n3. Delete all GCP IAM bindings for super admin users: in `IAM & Admin` -> `IAM` at the organization level, edit each super admin principal and remove all assigned roles. Repeat for all folders and projects.", + "AdditionalInformation": "", + "References": "", + "SubSection": "1.1 Organization Resource", + "DefaultValue": "By default, super administrator accounts from Google Workspace or Cloud Identity gain access to manage the GCP organization resource when it is created." + } + ] + }, + { + "Id": "1.1.3", + "Description": "Ensure Folders Are Structured By Environment And Sensitivity", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that GCP Resource Manager folders are structured primarily by environment (for example, production, non-production, sandbox) and sensitivity (for example, security, logging, shared services, regulated workloads), rather than mirroring the corporate org chart. Folders should group projects that share similar security requirements and controls so that appropriate Organization Policies, IAM policies, and other guardrails can be applied consistently at the folder level.", + "RationaleStatement": "A clear folder structure based on environment and sensitivity makes it easier to apply consistent guardrails and centralized security controls to projects that have similar risk profiles and compliance needs. Folders enable hierarchical policy inheritance where stricter controls can be applied to production folders while development folders can have more relaxed policies. Poorly defined or ad-hoc folder structures complicate policy management, increase the chance of misapplied controls, and can lead to mixing workloads with different data sensitivities. Without proper folder segregation, a compromised development project can be used to pivot into production resources and compliance auditing becomes significantly more complex.", + "ImpactStatement": "Restructuring folders by environment and sensitivity can require moving projects, changing inherited Organization Policies and IAM policies, and updating automation that assumes existing folder paths. This may introduce short-term operational overhead, including policy revalidation, testing of workloads under new guardrails, and coordination with application and platform teams.", + "AuditProcedure": "**From Google Cloud Admin Console**\n\n1. In the Cloud Console (https://console.cloud.google.com), select your organization and go to `IAM & Admin` -> `Manage resources` to view the organization hierarchy.\n2. Review and document the folder hierarchy (organization -> folders -> sub-folders -> projects).\n3. Evaluate whether top-level and key folders are clearly aligned to environment (production, non-production, sandbox) and sensitivity/function (security, logging, shared services, regulated).\n4. For each environment/sensitivity folder, sample projects and verify their primary workloads match the folder's stated purpose. Note any folders organized mainly by department/owner or that mix production and non-production workloads.", + "RemediationProcedure": "**From Google Cloud Admin Console**\n\n1. Work with security, platform, and application teams to agree on a small set of top-level folders (e.g., Security/Management, Shared Services/Infrastructure, Prod, Non-Prod). Define dedicated folders for highly regulated workloads.\n2. Create the folder structure under `IAM & Admin` -> `Manage resources` using `CREATE FOLDER`.\n3. Map each project to its target folder based on environment and sensitivity.\n4. Move projects to the environment/sensitivity-based folders (start with low-risk sandbox/non-production projects, test workloads, then proceed with production).\n5. Remove old folders that no longer reflect the target structure and update architecture docs and onboarding runbooks.", + "AdditionalInformation": "", + "References": "", + "SubSection": "1.1 Organization Resource" + } + ] + }, + { + "Id": "1.1.4", + "Description": "Ensure Organization Policies Are Configured For Centralized Constraints", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that one or more baseline Organization Policies are configured at the organization or folder level in accordance with enterprise security requirements. Organization Policies act as preventive guardrails that set centralized constraints on how resources can be configured across all projects within the organization hierarchy. These policies can enforce security invariants such as preventing public IP addresses on compute instances, requiring OS Login for SSH access, restricting resource locations to approved regions, enforcing uniform bucket-level access on Cloud Storage, or limiting IAM policy member domains to prevent external sharing.", + "RationaleStatement": "Organization Policies do not grant permissions but instead set organization-wide limits on resource configurations and service usage, regardless of local project-level IAM policies. Without baseline guardrail Organization Policies, each project can configure resources inconsistently, disable security features, use unapproved regions, allow public access to resources, or share data with external domains. Configuring standard Organization Policies at the organization or folder level enforces preventive, centralized control over high-risk configurations and supports consistent security posture at scale. Organization Policies are inherited down the resource hierarchy (organization -> folder -> project), making them an effective mechanism for enforcing security controls across large numbers of projects.", + "ImpactStatement": "Enforcing baseline Organization Policies can initially block some existing resource configurations, such as use of unapproved regions, creation of resources with public IPs, or external domain sharing. Teams may need to adjust deployment pipelines, Terraform configurations, and exception processes. Organizations should test policies in non-production folders before applying them broadly.", + "AuditProcedure": "**From Google Cloud Admin Console**\n\nPre-requisite: Document your organization's baseline guardrail requirements (e.g., constraints/gcp.resourceLocations, constraints/compute.requireOsLogin, constraints/compute.vmExternalIpAccess, constraints/storage.uniformBucketLevelAccess, constraints/iam.allowedPolicyMemberDomains).\n\n1. In the Cloud Console (https://console.cloud.google.com), select your organization and go to `IAM & Admin` -> `Organization Policies`. Review the policies set at the organization level and verify baseline security constraints are configured (enforced, exceptions, configured values).\n2. Verify policies are not overridden or disabled at folder or project levels (look for policies marked as `Customized`).\n3. Compare your documented baseline requirements against the configured Organization Policies and identify gaps.", + "RemediationProcedure": "IMPORTANT: Before enforcing Organization Policies, use dry run mode to preview which resources would be blocked and review violations in Cloud Asset Inventory before actual enforcement.\n\n**From Google Cloud Admin Console**\n\n1. In `IAM & Admin` -> `Organization Policies`, review existing policies and identify gaps based on your security requirements.\n2. For each baseline constraint, search for the constraint, click `Manage policy`, select `Override parent's policy` and configure it (boolean: `Enforcement On`; list: choose Deny all/Allow all or Custom values). Click `Set policy` and verify it shows as `Enforced`.\n3. Configure folder-level policies if different constraints are needed per environment, ensuring folder-level policies do not weaken organization-level security controls unless explicitly approved.", + "AdditionalInformation": "", + "References": "", + "SubSection": "1.1 Organization Resource" + } + ] + }, + { + "Id": "1.2", + "Description": "Ensure that Corporate Login Credentials are Used", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Use corporate login credentials instead of consumer accounts, such as Gmail accounts.", + "RationaleStatement": "It is recommended fully-managed corporate Google accounts be used for increased visibility, auditing, and controlling access to Cloud Platform resources. Email accounts based outside of the user's organization, such as consumer accounts, should not be used for business purposes.", + "ImpactStatement": "There will be increased overhead as maintaining accounts will now be required. For smaller organizations, this will not be an issue, but will balloon with size.", + "RemediationProcedure": "Remove all consumer Google accounts from IAM policies. Follow the documentation and setup corporate login accounts. **Prevention:** To ensure that no email addresses outside the organization can be granted IAM permissions to its Google Cloud projects, folders or organization, turn on the Organization Policy for `Domain Restricted Sharing`. Learn more at: [https://cloud.google.com/resource-manager/docs/organization-policy/restricting-domains](https://cloud.google.com/resource-manager/docs/organization-policy/restricting-domains)", + "AuditProcedure": "For each Google Cloud Platform project, list the accounts that have been granted access to that project: **From Google Cloud CLI** gcloud projects get-iam-policy PROJECT_ID Also list the accounts added on each folder: gcloud resource-manager folders get-iam-policy FOLDER_ID And list your organization's IAM policy: gcloud organizations get-iam-policy ORGANIZATION_ID No email accounts outside the organization domain should be granted permissions in the IAM policies. This excludes Google-owned service accounts.", + "AdditionalInformation": "", + "References": "https://support.google.com/work/android/answer/6371476:https://cloud.google.com/sdk/gcloud/reference/projects/get-iam-policy:https://cloud.google.com/sdk/gcloud/reference/resource-manager/folders/get-iam-policy:https://cloud.google.com/sdk/gcloud/reference/organizations/get-iam-policy:https://cloud.google.com/resource-manager/docs/organization-policy/restricting-domains:https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints", + "DefaultValue": "By default, no email addresses outside the organization's domain have access to its Google Cloud deployments, but any user email account can be added to the IAM policy for Google Cloud Platform projects, folders, or organizations.", + "SubSection": null + } + ] + }, + { + "Id": "1.3", + "Description": "Ensure that Multi-Factor Authentication is 'Enabled' for All Non-Service Accounts", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Setup multi-factor authentication for Google Cloud Platform accounts.", + "RationaleStatement": "Multi-factor authentication requires more than one mechanism to authenticate a user. This secures user logins from attackers exploiting stolen or weak credentials.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud Console** For each Google Cloud Platform project: 1. Identify non-service accounts. 1. Setup multi-factor authentication for each account.", + "AuditProcedure": "**From Google Cloud Console** For each Google Cloud Platform project, folder, or organization: 1. Identify non-service accounts. 1. Manually verify that multi-factor authentication for each account is set.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/solutions/securing-gcp-account-u2f:https://support.google.com/accounts/answer/185839", + "DefaultValue": "By default, multi-factor authentication is not set.", + "SubSection": null + } + ] + }, + { + "Id": "1.4", + "Description": "Ensure that Security Key Enforcement is Enabled for All Admin Accounts", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Setup Security Key Enforcement for Google Cloud Platform admin accounts.", + "RationaleStatement": "Google Cloud Platform users with Organization Administrator roles have the highest level of privilege in the organization. These accounts should be protected with the strongest form of two-factor authentication: Security Key Enforcement. Ensure that admins use Security Keys to log in instead of weaker second factors like SMS or one-time passwords (OTP). Security Keys are actual physical keys used to access Google Organization Administrator Accounts. They send an encrypted signature rather than a code, ensuring that logins cannot be phished.", + "ImpactStatement": "If an organization administrator loses access to their security key, the user could lose access to their account. For this reason, it is important to set up backup security keys.", + "RemediationProcedure": "1. Identify users with the Organization Administrator role. 2. Setup Security Key Enforcement for each account. Learn more at: [https://cloud.google.com/security-key/](https://cloud.google.com/security-key/)", + "AuditProcedure": "1. Identify users with Organization Administrator privileges: gcloud organizations get-iam-policy ORGANIZATION_ID Look for members granted the role roles/resourcemanager.organizationAdmin. 2. Manually verify that Security Key Enforcement has been enabled for each account.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/security-key/:https://gsuite.google.com/learn-more/key_for_working_smarter_faster_and_more_securely.html", + "DefaultValue": "By default, Security Key Enforcement is not enabled for Organization Administrators.", + "SubSection": null + } + ] + }, + { + "Id": "1.5", + "Description": "Ensure That There Are Only GCP-Managed Service Account Keys for Each Service Account", + "Checks": [ + "iam_sa_no_user_managed_keys" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "User-managed service accounts should not have user-managed keys.", + "RationaleStatement": "Anyone who has access to the keys will be able to access resources through the service account. GCP-managed keys are used by Cloud Platform services such as App Engine and Compute Engine. These keys cannot be downloaded. Google will keep the keys and automatically rotate them on an approximately weekly basis. User-managed keys are created, downloadable, and managed by users. They expire 10 years from creation. For user-managed keys, the user has to take ownership of key management activities which include: - Key storage - Key distribution - Key revocation - Key rotation - Protecting the keys from unauthorized users - Key recovery Even with key owner precautions, keys can be easily leaked by common development malpractices like checking keys into the source code or leaving them in the Downloads directory, or accidentally leaving them on support blogs/channels. It is recommended to prevent user-managed service account keys.", + "ImpactStatement": "Deleting user-managed service account keys may break communication with the applications using the corresponding keys.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the IAM page in the GCP Console using `https://console.cloud.google.com/iam-admin/iam` 2. In the left navigation pane, click `Service accounts`. All service accounts and their corresponding keys are listed. 3. Click the service account. 4. Click the `edit` and delete the keys. **From Google Cloud CLI** To delete a user managed Service Account Key, gcloud iam service-accounts keys delete --iam-account= **Prevention:** You can disable service account key creation through the `Disable service account key creation` Organization policy by visiting [https://console.cloud.google.com/iam-admin/orgpolicies/iam-disableServiceAccountKeyCreation](https://console.cloud.google.com/iam-admin/orgpolicies/iam-disableServiceAccountKeyCreation). Learn more at: [https://cloud.google.com/resource-manager/docs/organization-policy/restricting-service-accounts](https://cloud.google.com/resource-manager/docs/organization-policy/restricting-service-accounts) In addition, if you do not need to have service accounts in your project, you can also prevent the creation of service accounts through the `Disable service account creation` Organization policy: [https://console.cloud.google.com/iam-admin/orgpolicies/iam-disableServiceAccountCreation](https://console.cloud.google.com/iam-admin/orgpolicies/iam-disableServiceAccountCreation).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the IAM page in the GCP Console using `https://console.cloud.google.com/iam-admin/iam` 2. In the left navigation pane, click `Service accounts`. All service accounts and their corresponding keys are listed. 3. Click the service accounts and check if keys exist. **From Google Cloud CLI** List All the service accounts: gcloud iam service-accounts list Identify user-managed service accounts which have an account `EMAIL` ending with `iam.gserviceaccount.com` For each user-managed service account, list the keys managed by the user: gcloud iam service-accounts keys list --iam-account= --managed-by=user No keys should be listed.", + "AdditionalInformation": "A user-managed key cannot be created on GCP-Managed Service Accounts.", + "References": "https://cloud.google.com/iam/docs/understanding-service-accounts#managing_service_account_keys:https://cloud.google.com/resource-manager/docs/organization-policy/restricting-service-accounts", + "DefaultValue": "By default, there are no user-managed keys created for user-managed service accounts.", + "SubSection": null + } + ] + }, + { + "Id": "1.6", + "Description": "Ensure That Service Account Has No Admin Privileges", + "Checks": [ + "iam_sa_no_administrative_privileges" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "A service account is a special Google account that belongs to an application or a VM, instead of to an individual end-user. The application uses the service account to call the service's Google API so that users aren't directly involved. It's recommended not to use admin access for ServiceAccount.", + "RationaleStatement": "Service accounts represent service-level security of the Resources (application or a VM) which can be determined by the roles assigned to it. Enrolling ServiceAccount with Admin rights gives full access to an assigned application or a VM. A ServiceAccount Access holder can perform critical actions like delete, update change settings, etc. without user intervention. For this reason, it's recommended that service accounts not have Admin rights.", + "ImpactStatement": "Removing `*Admin` or `*admin` or `Editor` or `Owner` role assignments from service accounts may break functionality that uses impacted service accounts. Required role(s) should be assigned to impacted service accounts in order to restore broken functionalities.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `IAM & admin/IAM` using `https://console.cloud.google.com/iam-admin/iam` 2. Under the `IAM` Tab look for `VIEW BY PRINCIPALS` 3. Filter `PRINCIPALS` using `type : Service account` 4. Look for the Service Account with the Principal nomenclature: `SERVICE_ACCOUNT_NAME@PROJECT_ID.iam.gserviceaccount.com` 5. Identify `User-Managed user created` service account with roles containing `*Admin` or `*admin` or role matching `Editor` or role matching `Owner` under `Role` Column. 6. Click on `Edit (Pencil Icon)` for the Service Account, it will open all the roles which are assigned to the Service Account. 7. Click the `Delete bin` icon to remove the role from the Principal (service account in this case) **From Google Cloud CLI** gcloud projects get-iam-policy PROJECT_ID --format json > iam.json 1. Using a text editor, Remove `Role` which contains `roles/*Admin` or `roles/*admin` or matched `roles/editor` or matches 'roles/owner`. Add a role to the bindings array that defines the group members and the role for those members. For example, to grant the role roles/appengine.appViewer to the `ServiceAccount` which is roles/editor, you would change the example shown below as follows: { bindings: [ { members: [ serviceAccount:our-project-123@appspot.gserviceaccount.com, ], role: roles/appengine.appViewer }, { members: [ user:email1@gmail.com ], role: roles/owner }, { members: [ serviceAccount:our-project-123@appspot.gserviceaccount.com, serviceAccount:123456789012-compute@developer.gserviceaccount.com ], role: roles/editor } ], etag: BwUjMhCsNvY= } 2. Update the project's IAM policy: gcloud projects set-iam-policy PROJECT_ID iam.json ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `IAM & admin/IAM` using `https://console.cloud.google.com/iam-admin/iam` 2. Under the `IAM` Tab look for `VIEW BY PRINCIPALS` 3. Filter `PRINCIPALS` using `type : Service account` 4. Look for the Service Account with the nomenclature: `SERVICE_ACCOUNT_NAME@PROJECT_ID.iam.gserviceaccount.com` 5. Ensure that there are no such Service Accounts with roles containing `*Admin` or `*admin` or role matching `Editor` or role matching `Owner` under `Role` column. **From Google Cloud CLI** 1. Get the policy that you want to modify, and write it to a JSON file: gcloud projects get-iam-policy PROJECT_ID --format json > iam.json 2. The contents of the JSON file will look similar to the following. Note that `role` of members group associated with each `serviceaccount` does not contain `*Admin` or `*admin` or does not match `roles/editor` or does not match `roles/owner`. This recommendation is only applicable to `User-Managed user-created` service accounts. These accounts have the nomenclature: `SERVICE_ACCOUNT_NAME@PROJECT_ID.iam.gserviceaccount.com`. Note that some Google-managed, Google-created service accounts have the same naming format, and should be excluded (e.g., `appsdev-apps-dev-script-auth@system.gserviceaccount.com` which needs the Owner role). **Sample Json output:** { bindings: [ { members: [ serviceAccount:our-project-123@appspot.gserviceaccount.com, ], role: roles/appengine.appAdmin }, { members: [ user:email1@gmail.com ], role: roles/owner }, { members: [ serviceAccount:our-project-123@appspot.gserviceaccount.com, serviceAccount:123456789012-compute@developer.gserviceaccount.com ], role: roles/editor } ], etag: BwUjMhCsNvY=, version: 1 }", + "AdditionalInformation": "Default (user-managed but not user-created) service accounts have the `Editor (roles/editor)` role assigned to them to support GCP services they offer. Such Service accounts are: `PROJECT_NUMBER-compute@developer.gserviceaccount.com`, `PROJECT_ID@appspot.gserviceaccount.com`.", + "References": "https://cloud.google.com/sdk/gcloud/reference/iam/service-accounts/:https://cloud.google.com/iam/docs/understanding-roles:https://cloud.google.com/iam/docs/understanding-service-accounts", + "DefaultValue": "User Managed (and not user-created) default service accounts have the `Editor (roles/editor)` role assigned to them to support GCP services they offer. By default, there are no roles assigned to `User Managed User created` service accounts.", + "SubSection": null + } + ] + }, + { + "Id": "1.7", + "Description": "Ensure That IAM Users Are Not Assigned the Service Account User or Service Account Token Creator Roles at Project Level", + "Checks": [ + "iam_no_service_roles_at_project_level" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to assign the `Service Account User (iam.serviceAccountUser)` and `Service Account Token Creator (iam.serviceAccountTokenCreator)` roles to a user for a specific service account rather than assigning the role to a user at project level.", + "RationaleStatement": "A service account is a special Google account that belongs to an application or a virtual machine (VM), instead of to an individual end-user. Application/VM-Instance uses the service account to call the service's Google API so that users aren't directly involved. In addition to being an identity, a service account is a resource that has IAM policies attached to it. These policies determine who can use the service account. Users with IAM roles to update the App Engine and Compute Engine instances (such as App Engine Deployer or Compute Instance Admin) can effectively run code as the service accounts used to run these instances, and indirectly gain access to all the resources for which the service accounts have access. Similarly, SSH access to a Compute Engine instance may also provide the ability to execute code as that instance/Service account. Based on business needs, there could be multiple user-managed service accounts configured for a project. Granting the `iam.serviceAccountUser` or `iam.serviceAccountTokenCreator` roles to a user for a project gives the user access to all service accounts in the project, including service accounts that may be created in the future. This can result in elevation of privileges by using service accounts and corresponding `Compute Engine instances`. In order to implement `least privileges` best practices, IAM users should not be assigned the `Service Account User` or `Service Account Token Creator` roles at the project level. Instead, these roles should be assigned to a user for a specific service account, giving that user access to the service account. The `Service Account User` allows a user to bind a service account to a long-running job service, whereas the `Service Account Token Creator` role allows a user to directly impersonate (or assert) the identity of a service account.", + "ImpactStatement": "After revoking `Service Account User` or `Service Account Token Creator` roles at the project level from all impacted user account(s), these roles should be assigned to a user(s) for specific service account(s) according to business needs.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the IAM page in the GCP Console by visiting: [https://console.cloud.google.com/iam-admin/iam](https://console.cloud.google.com/iam-admin/iam). 2. Click on the filter table text bar. Type `Role: Service Account User` 3. Click the `Delete Bin` icon in front of the role `Service Account User` for every user listed as a result of a filter. 4. Click on the filter table text bar. Type `Role: Service Account Token Creator` 5. Click the `Delete Bin` icon in front of the role `Service Account Token Creator` for every user listed as a result of a filter. **From Google Cloud CLI** 1. Using a text editor, remove the bindings with the `roles/iam.serviceAccountUser` or `roles/iam.serviceAccountTokenCreator`. For example, you can use the iam.json file shown below as follows: { bindings: [ { members: [ serviceAccount:our-project-123@appspot.gserviceaccount.com, ], role: roles/appengine.appViewer }, { members: [ user:email1@gmail.com ], role: roles/owner }, { members: [ serviceAccount:our-project-123@appspot.gserviceaccount.com, serviceAccount:123456789012-compute@developer.gserviceaccount.com ], role: roles/editor } ], etag: BwUjMhCsNvY= } 2. Update the project's IAM policy: gcloud projects set-iam-policy PROJECT_ID iam.json ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the IAM page in the GCP Console by visiting [https://console.cloud.google.com/iam-admin/iam](https://console.cloud.google.com/iam-admin/iam) 2. Click on the filter table text bar, Type `Role: Service Account User`. 3. Ensure no user is listed as a result of the filter. 4. Click on the filter table text bar, Type `Role: Service Account Token Creator`. 3. Ensure no user is listed as a result of the filter. **From Google Cloud CLI** To ensure IAM users are not assigned Service Account User role at the project level: gcloud projects get-iam-policy PROJECT_ID --format json | jq '.bindings[].role' | grep roles/iam.serviceAccountUser gcloud projects get-iam-policy PROJECT_ID --format json | jq '.bindings[].role' | grep roles/iam.serviceAccountTokenCreator These commands should not return any output.", + "AdditionalInformation": "To assign the role `roles/iam.serviceAccountUser` or `roles/iam.serviceAccountTokenCreator` to a user role on a service account instead of a project: 1. Go to [https://console.cloud.google.com/projectselector/iam-admin/serviceaccounts](https://console.cloud.google.com/projectselector/iam-admin/serviceaccounts) 2. Select ` Target Project` 3. Select `target service account`. Click `Permissions` on the top bar. It will open permission pane on right side of the page 4. Add desired members with `Service Account User` or `Service Account Token Creator` role.", + "References": "https://cloud.google.com/iam/docs/service-accounts:https://cloud.google.com/iam/docs/granting-roles-to-service-accounts:https://cloud.google.com/iam/docs/understanding-roles:https://cloud.google.com/iam/docs/granting-changing-revoking-access:https://console.cloud.google.com/iam-admin/iam", + "DefaultValue": "By default, users do not have the Service Account User or Service Account Token Creator role assigned at project level.", + "SubSection": null + } + ] + }, + { + "Id": "1.8", + "Description": "Ensure User-Managed/External Keys for Service Accounts Are Rotated Every 90 Days or Fewer", + "Checks": [ + "iam_sa_user_managed_key_rotate_90_days" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Service Account keys consist of a key ID (Private_key_Id) and Private key, which are used to sign programmatic requests users make to Google cloud services accessible to that particular service account. It is recommended that all Service Account keys are regularly rotated.", + "RationaleStatement": "Rotating Service Account keys will reduce the window of opportunity for an access key that is associated with a compromised or terminated account to be used. Service Account keys should be rotated to ensure that data cannot be accessed with an old key that might have been lost, cracked, or stolen. Each service account is associated with a key pair managed by Google Cloud Platform (GCP). It is used for service-to-service authentication within GCP. Google rotates the keys daily. GCP provides the option to create one or more user-managed (also called external key pairs) key pairs for use from outside GCP (for example, for use with Application Default Credentials). When a new key pair is created, the user is required to download the private key (which is not retained by Google). With external keys, users are responsible for keeping the private key secure and other management operations such as key rotation. External keys can be managed by the IAM API, gcloud command-line tool, or the Service Accounts page in the Google Cloud Platform Console. GCP facilitates up to 10 external service account keys per service account to facilitate key rotation.", + "ImpactStatement": "Rotating service account keys will break communication for dependent applications. Dependent applications need to be configured manually with the new key `ID` displayed in the `Service account keys` section and the `private key` downloaded by the user.", + "RemediationProcedure": "**From Google Cloud Console** **Delete any external (user-managed) Service Account Key older than 90 days:** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the Section `Service Account Keys`, for every external (user-managed) service account key where `creation date` is greater than or equal to the past 90 days, click `Delete Bin Icon` to `Delete Service Account key` **Create a new external (user-managed) Service Account Key for a Service Account:** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. Click `Create Credentials` and Select `Service Account Key`. 3. Choose the service account in the drop-down list for which an External (user-managed) Service Account key needs to be created. 4. Select the desired key type format among `JSON` or `P12`. 5. Click `Create`. It will download the `private key`. Keep it safe. 6. Click `Close` if prompted. 7. The site will redirect to the `APIs & ServicesCredentials` page. Make a note of the new `ID` displayed in the `Service account keys` section.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `Service Account Keys`, for every External (user-managed) service account key listed ensure the `creation date` is within the past 90 days. **From Google Cloud CLI** 1. List all Service accounts from a project. gcloud iam service-accounts list 2. For every service account list service account keys. gcloud iam service-accounts keys list --iam-account [Service_Account_Email_Id] --format=json 3. Ensure every service account key for a service account has a `validAfterTime` value within the past 90 days.", + "AdditionalInformation": "For user-managed Service Account key(s), key management is entirely the user's responsibility.", + "References": "https://cloud.google.com/iam/docs/understanding-service-accounts#managing_service_account_keys:https://cloud.google.com/sdk/gcloud/reference/iam/service-accounts/keys/list:https://cloud.google.com/iam/docs/service-accounts", + "DefaultValue": "GCP does not provide an automation option for External (user-managed) Service key rotation.", + "SubSection": null + } + ] + }, + { + "Id": "1.9", + "Description": "Ensure That Separation of Duties Is Enforced While Assigning Service Account Related Roles to Users", + "Checks": [ + "iam_role_kms_enforce_separation_of_duties" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that the principle of 'Separation of Duties' is enforced while assigning service-account related roles to users.", + "RationaleStatement": "The built-in/predefined IAM role `Service Account admin` allows the user/identity to create, delete, and manage service account(s). The built-in/predefined IAM role `Service Account User` allows the user/identity (with adequate privileges on Compute and App Engine) to assign service account(s) to Apps/Compute Instances. Separation of duties is the concept of ensuring that one individual does not have all necessary permissions to be able to complete a malicious action. In Cloud IAM - service accounts, this could be an action such as using a service account to access resources that user should not normally have access to. Separation of duties is a business control typically used in larger organizations, meant to help avoid security or privacy incidents and errors. It is considered best practice. No user should have `Service Account Admin` and `Service Account User` roles assigned at the same time.", + "ImpactStatement": "The removed role should be assigned to a different user based on business needs.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `IAM & Admin/IAM` using `https://console.cloud.google.com/iam-admin/iam`. 2. For any member having both `Service Account Admin` and `Service account User` roles granted/assigned, click the `Delete Bin` icon to remove either role from the member. Removal of a role should be done based on the business requirements.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `IAM & Admin/IAM` using `https://console.cloud.google.com/iam-admin/iam`. 2. Ensure no member has the roles `Service Account Admin` and `Service account User` assigned together. **From Google Cloud CLI** 1. List all users and role assignments: gcloud projects get-iam-policy [Project_ID] --format json | jq -r '[ ([Service_Account_Admin_and_User] | (., map(length*-))), ( [ .bindings[] | select(.role == roles/iam.serviceAccountAdmin or .role == roles/iam.serviceAccountUser).members[] ] | group_by(.) | map({User: ., Count: length}) | .[] | select(.Count == 2).User | unique ) ] | .[] | @tsv' 2. All common users listed under `Service_Account_Admin_and_User` are assigned both the `roles/iam.serviceAccountAdmin` and `roles/iam.serviceAccountUser` roles.", + "AdditionalInformation": "Users granted with Owner (roles/owner) and Editor (roles/editor) have privileges equivalent to `Service Account Admin` and `Service Account User`. To avoid the misuse, Owner and Editor roles should be granted to very limited users and Use of these primitive privileges should be minimal. These requirements are addressed in separate recommendations.", + "References": "https://cloud.google.com/iam/docs/service-accounts:https://cloud.google.com/iam/docs/understanding-roles:https://cloud.google.com/iam/docs/granting-roles-to-service-accounts", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "1.10", + "Description": "Ensure That Cloud KMS Cryptokeys Are Not Anonymously or Publicly Accessible", + "Checks": [ + "kms_key_not_publicly_accessible" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that the IAM policy on Cloud KMS `cryptokeys` should restrict anonymous and/or public access.", + "RationaleStatement": "Granting permissions to `allUsers` or `allAuthenticatedUsers` allows anyone to access the dataset. Such access might not be desirable if sensitive data is stored at the location. In this case, ensure that anonymous and/or public access to a Cloud KMS `cryptokey` is not allowed.", + "ImpactStatement": "Removing the binding for `allUsers` and `allAuthenticatedUsers` members denies accessing `cryptokeys` to anonymous or public users.", + "RemediationProcedure": "**From Google Cloud CLI** 1. List all Cloud KMS `Cryptokeys`. gcloud kms keys list --keyring=[key_ring_name] --location=global --format=json | jq '.[].name' 2. Remove IAM policy binding for a KMS key to remove access to `allUsers` and `allAuthenticatedUsers` using the below command. gcloud kms keys remove-iam-policy-binding [key_name] --keyring=[key_ring_name] --location=global --member='allAuthenticatedUsers' --role='[role]' gcloud kms keys remove-iam-policy-binding [key_name] --keyring=[key_ring_name] --location=global --member='allUsers' --role='[role]' ", + "AuditProcedure": "**From Google Cloud CLI** 1. List all Cloud KMS `Cryptokeys`. gcloud kms keys list --keyring=[key_ring_name] --location=global --format=json | jq '.[].name' 2. Ensure the below command's output does not contain `allUsers` or `allAuthenticatedUsers`. gcloud kms keys get-iam-policy [key_name] --keyring=[key_ring_name] --location=global --format=json | jq '.bindings[].members[]' ", + "AdditionalInformation": "[key_ring_name] : Is the resource ID of the key ring, which is the fully-qualified Key ring name. This value is case-sensitive and in the form: projects/PROJECT_ID/locations/LOCATION/keyRings/KEY_RING You can retrieve the key ring resource ID using the Cloud Console: 1. Open the `Cryptographic Keys` page in the Cloud Console. 2. For the key ring whose resource ID you are retrieving, click the `More icon (3 vertical dots)`. 3. Click `Copy Resource ID`. The resource ID for the key ring is copied to your clipboard. [key_name] : Is the resource ID of the key, which is the fully-qualified CryptoKey name. This value is case-sensitive and in the form: projects/PROJECT_ID/locations/LOCATION/keyRings/KEY_RING/cryptoKeys/KEY You can retrieve the key resource ID using the Cloud Console: 1. Open the `Cryptographic Keys` page in the Cloud Console. 2. Click the name of the key ring that contains the key. 3. For the key whose resource ID you are retrieving, click the `More icon (3 vertical dots)`. 4. Click `Copy Resource ID`. The resource ID for the key is copied to your clipboard. [role] : The role to remove the member from.", + "References": "https://cloud.google.com/sdk/gcloud/reference/kms/keys/remove-iam-policy-binding:https://cloud.google.com/sdk/gcloud/reference/kms/keys/set-iam-policy:https://cloud.google.com/sdk/gcloud/reference/kms/keys/get-iam-policy:https://cloud.google.com/kms/docs/object-hierarchy#key_resource_id", + "DefaultValue": "By default Cloud KMS does not allow access to `allUsers` or `allAuthenticatedUsers`.", + "SubSection": null + } + ] + }, + { + "Id": "1.11", + "Description": "Ensure KMS Encryption Keys Are Rotated Within a Period of 90 Days", + "Checks": [ + "kms_key_rotation_max_90_days" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "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 `INTEGER[UNIT]`, where units can be one of seconds (s), minutes (m), hours (h) or days (d).", + "RationaleStatement": "Set a key rotation period and starting time. A key can be created with a specified `rotation period`, which is the time between when new key versions are generated automatically. A key can also be created with a specified next rotation time. A key is a named object representing a `cryptographic key` used for a specific purpose. The key material, the actual bits used for `encryption`, can change over time as new key versions are created. A key is used to protect some `corpus of data`. A collection of files could be encrypted with the same key and people with `decrypt` permissions on that key would be able to decrypt those files. Therefore, it's necessary to make sure the `rotation period` is set to a specific time.", + "ImpactStatement": "After a successful key rotation, the older key version is required in order to decrypt the data encrypted by that previous key version.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `Cryptographic Keys` by visiting: [https://console.cloud.google.com/security/kms](https://console.cloud.google.com/security/kms). 2. Click on the specific key ring 3. From the list of keys, choose the specific key and Click on `Right side pop up the blade (3 dots)`. 4. Click on `Edit rotation period`. 5. On the pop-up window, `Select a new rotation period` in days which should be less than 90 and then choose `Starting on` date (date from which the rotation period begins). **From Google Cloud CLI** 1. Update and schedule rotation by `ROTATION_PERIOD` and `NEXT_ROTATION_TIME` for each key: gcloud kms keys update new --keyring=KEY_RING --location=LOCATION --next-rotation-time=NEXT_ROTATION_TIME --rotation-period=ROTATION_PERIOD ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Cryptographic Keys` by visiting: [https://console.cloud.google.com/security/kms](https://console.cloud.google.com/security/kms). 2. Click on each key ring, then ensure each key in the keyring has `Next Rotation` set for less than 90 days from the current date. **From Google Cloud CLI** 1. Ensure rotation is scheduled by `ROTATION_PERIOD` and `NEXT_ROTATION_TIME` for each key : gcloud kms keys list --keyring= --location= --format=json'(rotationPeriod)' Ensure outcome values for `rotationPeriod` and `nextRotationTime` satisfy the below criteria: `rotationPeriod is <= 129600m` `rotationPeriod is <= 7776000s` `rotationPeriod is <= 2160h` `rotationPeriod is <= 90d` `nextRotationTime is <= 90days` from current DATE", + "AdditionalInformation": "- Key rotation does NOT re-encrypt already encrypted data with the newly generated key version. If you suspect unauthorized use of a key, you should re-encrypt the data protected by that key and then disable or schedule destruction of the prior key version. - It is not recommended to rely solely on irregular rotation, but rather to use irregular rotation if needed in conjunction with a regular rotation schedule.", + "References": "https://cloud.google.com/kms/docs/key-rotation#frequency_of_key_rotation:https://cloud.google.com/kms/docs/re-encrypt-data", + "DefaultValue": "By default, KMS encryption keys are rotated every 90 days.", + "SubSection": null + } + ] + }, + { + "Id": "1.12", + "Description": "Ensure That Separation of Duties Is Enforced While Assigning KMS Related Roles to Users", + "Checks": [ + "iam_role_kms_enforce_separation_of_duties" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that the principle of 'Separation of Duties' is enforced while assigning KMS related roles to users.", + "RationaleStatement": "The built-in/predefined IAM role `Cloud KMS Admin` allows the user/identity to create, delete, and manage service account(s). The built-in/predefined IAM role `Cloud KMS CryptoKey Encrypter/Decrypter` allows the user/identity (with adequate privileges on concerned resources) to encrypt and decrypt data at rest using an encryption key(s). The built-in/predefined IAM role `Cloud KMS CryptoKey Encrypter` allows the user/identity (with adequate privileges on concerned resources) to encrypt data at rest using an encryption key(s). The built-in/predefined IAM role `Cloud KMS CryptoKey Decrypter` allows the user/identity (with adequate privileges on concerned resources) to decrypt data at rest using an encryption key(s). Separation of duties is the concept of ensuring that one individual does not have all necessary permissions to be able to complete a malicious action. In Cloud KMS, this could be an action such as using a key to access and decrypt data a user should not normally have access to. Separation of duties is a business control typically used in larger organizations, meant to help avoid security or privacy incidents and errors. It is considered best practice. No user(s) should have `Cloud KMS Admin` and any of the `Cloud KMS CryptoKey Encrypter/Decrypter`, `Cloud KMS CryptoKey Encrypter`, `Cloud KMS CryptoKey Decrypter` roles assigned at the same time.", + "ImpactStatement": "Removed roles should be assigned to another user based on business needs.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `IAM & Admin/IAM` using `https://console.cloud.google.com/iam-admin/iam` 2. For any member having `Cloud KMS Admin` and any of the `Cloud KMS CryptoKey Encrypter/Decrypter`, `Cloud KMS CryptoKey Encrypter`, `Cloud KMS CryptoKey Decrypter` roles granted/assigned, click the `Delete Bin` icon to remove the role from the member. Note: Removing a role should be done based on the business requirement.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `IAM & Admin/IAM` by visiting: [https://console.cloud.google.com/iam-admin/iam](https://console.cloud.google.com/iam-admin/iam) 2. Ensure no member has the roles `Cloud KMS Admin` and any of the `Cloud KMS CryptoKey Encrypter/Decrypter`, `Cloud KMS CryptoKey Encrypter`, `Cloud KMS CryptoKey Decrypter` assigned. **From Google Cloud CLI** 1. List all users and role assignments: gcloud projects get-iam-policy PROJECT_ID 2. Ensure that there are no common users found in the member section for roles `cloudkms.admin` and any one of `Cloud KMS CryptoKey Encrypter/Decrypter`, `Cloud KMS CryptoKey Encrypter`, `Cloud KMS CryptoKey Decrypter`", + "AdditionalInformation": "Users granted with Owner (roles/owner) and Editor (roles/editor) have privileges equivalent to `Cloud KMS Admin` and `Cloud KMS CryptoKey Encrypter/Decrypter`. To avoid misuse, Owner and Editor roles should be granted to a very limited group of users. Use of these primitive privileges should be minimal.", + "References": "https://cloud.google.com/kms/docs/separation-of-duties", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "1.13", + "Description": "Ensure API Keys Only Exist for Active Services", + "Checks": [ + "apikeys_key_exists" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "API Keys should only be used for services in cases where other authentication methods are unavailable. Unused keys with their permissions in tact may still exist within a project. Keys are insecure because they can be viewed publicly, such as from within a browser, or they can be accessed on a device where the key resides. It is recommended to use standard authentication flow instead.", + "RationaleStatement": "To avoid the security risk in using API keys, it is recommended to use standard authentication flow instead. Security risks involved in using API-Keys appear below: - API keys are simple encrypted strings - API keys do not identify the user or the application making the API request - API keys are typically accessible to clients, making it easy to discover and steal an API key", + "ImpactStatement": "Deleting an API key will break dependent applications (if any).", + "RemediationProcedure": "**From Console:** 1. Go to `APIs & ServicesCredentials` using https://console.cloud.google.com/apis/credentials. 1. In the section `API Keys`, to delete API Keys: Click the `Delete Bin Icon` in front of every `API Key Name`. **From Google Cloud Command Line** 1. Run the following from within the project you wish to audit gcloud services api-keys list --filter 1. Run the following command, providing the ID of the key or fully qualified identifier for the key for : gcloud services api-keys delete ", + "AuditProcedure": "**From Console:** 1. From within the Project you wish to audit Go to `APIs & ServicesCredentials`. 1. In the section `API Keys`, no API key should be listed. **From Google Cloud Command Line** 1. Run the following from within the project you wish to audit gcloud services api-keys list --filter 1. There should be no keys listed at the project level.", + "AdditionalInformation": "Google recommends using the standard authentication flow instead of using API keys. However, there are limited cases where API keys are more appropriate. For example, if there is a mobile application that needs to use the Google Cloud Translation API, but doesn't otherwise need a backend server, API keys are the simplest way to authenticate to that API. If a business requires API keys to be used, then the API keys should be secured properly.", + "References": "https://cloud.google.com/docs/authentication/api-keys:https://cloud.google.com/sdk/gcloud/reference/services/api-keys/list:https://cloud.google.com/docs/authentication:https://cloud.google.com/sdk/gcloud/reference/services/api-keys/delete", + "DefaultValue": "By default, API keys are not created for a project.", + "SubSection": null + } + ] + }, + { + "Id": "1.14", + "Description": "Ensure API Keys Are Restricted To Use by Only Specified Hosts and Apps", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "API Keys should only be used for services in cases where other authentication methods are unavailable. In this case, unrestricted keys are insecure because they can be viewed publicly, such as from within a browser, or they can be accessed on a device where the key resides. It is recommended to restrict API key usage to trusted hosts, HTTP referrers and apps. It is recommended to use the more secure standard authentication flow instead.", + "RationaleStatement": "Security risks involved in using API-Keys appear below: - API keys are simple encrypted strings - API keys do not identify the user or the application making the API request - API keys are typically accessible to clients, making it easy to discover and steal an API key In light of these potential risks, Google recommends using the standard authentication flow instead of API keys. However, there are limited cases where API keys are more appropriate. For example, if there is a mobile application that needs to use the Google Cloud Translation API, but doesn't otherwise need a backend server, API keys are the simplest way to authenticate to that API. In order to reduce attack vectors, API-Keys can be restricted only to trusted hosts, HTTP referrers and applications.", + "ImpactStatement": "Setting `Application Restrictions` may break existing application functioning, if not done carefully.", + "RemediationProcedure": "**From Google Cloud Console** ***Leaving Keys in Place*** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `API Keys`, Click the `API Key Name`. The API Key properties display on a new page. 3. In the `Key restrictions` section, set the application restrictions to any of `HTTP referrers, IP addresses, Android apps, iOS apps`. 4. Click `Save`. 1. Repeat steps 2,3,4 for every unrestricted API key. **Note:** Do not set `HTTP referrers` to wild-cards (* or *.[TLD] or *.[TLD]/*) allowing access to any/wide HTTP referrer(s) Do not set `IP addresses` and referrer to `any host (0.0.0.0 or 0.0.0.0/0 or ::0)` ***Removing Keys*** Another option is to remove the keys entirely. 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `API Keys`, select the checkbox next to each key you wish to remove 3. Select `Delete` and confirm.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 1. In the section `API Keys`, Click the `API Key Name`. The API Key properties display on a new page. 1. For every API Key, ensure the section `Key restrictions` parameter `Application restrictions` is not set to `None`. Or, 1. Ensure `Application restrictions` is set to `HTTP referrers` and the referrer is not set to wild-cards `(* or *.[TLD] or *.[TLD]/*) allowing access to any/wide HTTP referrer(s)` Or, 1. Ensure `Application restrictions` is set to `IP addresses` and referrer is not set to `any host (0.0.0.0 or 0.0.0.0/0 or ::0)` **From Google Cloud Command Line** 1. Run the following from within the project you wish to audit gcloud services api-keys list --filter=-restrictions:* --format=table[box](displayName:label='Key With No Restrictions') ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/docs/authentication/api-keys:https://cloud.google.com/sdk/gcloud/reference/services/api-keys/list:https://cloud.google.com/sdk/gcloud/reference/services/api-keys/update", + "DefaultValue": "By default, `Application Restrictions` are set to `None`.", + "SubSection": null + } + ] + }, + { + "Id": "1.15", + "Description": "Ensure API Keys Are Restricted to Only APIs That Application Needs Access", + "Checks": [ + "apikeys_api_restrictions_configured" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "API Keys should only be used for services in cases where other authentication methods are unavailable. API keys are always at risk because they can be viewed publicly, such as from within a browser, or they can be accessed on a device where the key resides. It is recommended to restrict API keys to use (call) only APIs required by an application.", + "RationaleStatement": "Security risks involved in using API-Keys are below: - API keys are simple encrypted strings - API keys do not identify the user or the application making the API request - API keys are typically accessible to clients, making it easy to discover and steal an API key In light of these potential risks, Google recommends using the standard authentication flow instead of API-Keys. However, there are limited cases where API keys are more appropriate. For example, if there is a mobile application that needs to use the Google Cloud Translation API, but doesn't otherwise need a backend server, API keys are the simplest way to authenticate to that API. In order to reduce attack surfaces by providing `least privileges`, API-Keys can be restricted to use (call) only APIs required by an application.", + "ImpactStatement": "Setting `API restrictions` may break existing application functioning, if not done carefully.", + "RemediationProcedure": "**From Console:** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `API Keys`, Click the `API Key Name`. The API Key properties display on a new page. 3. In the `Key restrictions` section go to `API restrictions`. 4. Click the `Select API` drop-down to choose an API. 5. Click `Save`. 6. Repeat steps 2,3,4,5 for every unrestricted API key **Note:** Do not set `API restrictions` to `Google Cloud APIs`, as this option allows access to all services offered by Google cloud. **From Google Cloud CLI** 1. List all API keys. gcloud services api-keys list 2. Note the `UID` of the key to add restrictions to. 3. Run the update command with the appropriate API target service or flags file with API target services and methods to add the required restrictions. Command with appropriate API target service: gcloud services api-keys update --api-target=service= Command with flags file: gcloud services api-keys update --flags-file=.yaml Content of flags file: - --api-target: service: foo.service.com - --api-target: service: bar.service.com methods: - foomethod - barmethod Note: Flags can be found by running: gcloud services api-keys update --help Note: Services can be found by running: gcloud services list or in this documentation https://cloud.google.com/sdk/gcloud/reference/services/api-keys/update", + "AuditProcedure": "**From Console:** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `API Keys`, Click the `API Key Name`. The API Key properties display on a new page. 3. For every API Key, ensure the section `Key restrictions` parameter `API restrictions` is not set to `None`. Or, Ensure `API restrictions` is not set to `Google Cloud APIs` **Note:** `Google Cloud APIs` represents the API collection of all cloud services/APIs offered by Google cloud. **From Google Cloud CLI** 1. List all API Keys. gcloud services api-keys list Each key should have a line that says `restrictions:` followed by varying parameters and NOT have a line saying `- service: cloudapis.googleapis.com` as shown here restrictions: apiTargets: - service: cloudapis.googleapis.com ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/docs/authentication/api-keys:https://cloud.google.com/apis/docs/overview", + "DefaultValue": "By default, `API restrictions` are set to `None`.", + "SubSection": null + } + ] + }, + { + "Id": "1.16", + "Description": "Ensure API Keys Are Rotated Every 90 Days", + "Checks": [ + "apikeys_key_rotated_in_90_days" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "API Keys should only be used for services in cases where other authentication methods are unavailable. If they are in use it is recommended to rotate API keys every 90 days.", + "RationaleStatement": "Security risks involved in using API-Keys are listed below: - API keys are simple encrypted strings - API keys do not identify the user or the application making the API request - API keys are typically accessible to clients, making it easy to discover and steal an API key Because of these potential risks, Google recommends using the standard authentication flow instead of API Keys. However, there are limited cases where API keys are more appropriate. For example, if there is a mobile application that needs to use the Google Cloud Translation API, but doesn't otherwise need a backend server, API keys are the simplest way to authenticate to that API. Once a key is stolen, it has no expiration, meaning it may be used indefinitely unless the project owner revokes or regenerates the key. Rotating API keys will reduce the window of opportunity for an access key that is associated with a compromised or terminated account to be used. API keys should be rotated to ensure that data cannot be accessed with an old key that might have been lost, cracked, or stolen.", + "ImpactStatement": "`Regenerating Key` may break existing client connectivity as the client will try to connect with older API keys they have stored on devices.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `API Keys`, Click the `API Key Name`. The API Key properties display on a new page. 3. Click `REGENERATE KEY` to rotate API key. 4. Click `Save`. 5. Repeat steps 2,3,4 for every API key that has not been rotated in the last 90 days. **Note:** Do not set `HTTP referrers` to wild-cards (* or *.[TLD] or *.[TLD]/*) allowing access to any/wide HTTP referrer(s) Do not set `IP addresses` and referrer to `any host (0.0.0.0 or 0.0.0.0/0 or ::0)` **From Google Cloud CLI** There is not currently a way to regenerate and API key using gcloud commands. To 'regenerate' a key you will need to create a new one, duplicate the restrictions from the key being rotated, and delete the old key. 1. List existing keys. gcloud services api-keys list 2. Note the `UID` and restrictions of the key to regenerate. 3. Run this command to create a new API key. is the display name of the new key. ` gcloud services api-keys create --display-name= ` Note the `UID` of the newly created key 4. Run the update command to add required restrictions. Note - the restriction may vary for each key. Refer to this documentation for the appropriate flags. https://cloud.google.com/sdk/gcloud/reference/services/api-keys/update gcloud services api-keys update 5. Delete the old key. gcloud services api-keys delete ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `API Keys`, for every key ensure the `creation date` is less than 90 days. **From Google Cloud CLI** To list keys, use the command gcloud services api-keys list Ensure the date in `createTime` is within 90 days.", + "AdditionalInformation": "There is no option to automatically regenerate (rotate) API keys periodically.", + "References": "https://developers.google.com/maps/api-security-best-practices#regenerate-apikey:https://cloud.google.com/sdk/gcloud/reference/services/api-keys/update", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "1.17", + "Description": "Ensure Essential Contacts is Configured for Organization", + "Checks": [ + "iam_organization_essential_contacts_configured" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that Essential Contacts is configured to designate email addresses for Google Cloud services to notify of important technical or security information.", + "RationaleStatement": "Many Google Cloud services, such as Cloud Billing, send out notifications to share important information with Google Cloud users. By default, these notifications are sent to members with certain Identity and Access Management (IAM) roles. With Essential Contacts, you can customize who receives notifications by providing your own list of contacts.", + "ImpactStatement": "There is no charge for Essential Contacts except for the 'Technical Incidents' category that is only available to premium support customers.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `Essential Contacts` by visiting https://console.cloud.google.com/iam-admin/essential-contacts 2. Make sure the organization appears in the resource selector at the top of the page. The resource selector tells you what project, folder, or organization you are currently managing contacts for. 3. Click `+Add contact` 4. In the `Email` and `Confirm Email` fields, enter the email address of the contact. 5. From the `Notification categories` drop-down menu, select the notification categories that you want the contact to receive communications for. 6. Click `Save` **From Google Cloud CLI** 1. To add an organization Essential Contacts run a command: gcloud essential-contacts create --email= --notification-categories= --organization= ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Essential Contacts` by visiting https://console.cloud.google.com/iam-admin/essential-contacts 2. Make sure the organization appears in the resource selector at the top of the page. The resource selector tells you what project, folder, or organization you are currently managing contacts for. 3. Ensure that appropriate email addresses are configured for each of the following notification categories: - `Legal` - `Security` - `Suspension` - `Technical` Alternatively, appropriate email addresses can be configured for the `All` notification category to receive all possible important notifications. **From Google Cloud CLI** 1. To list all configured organization Essential Contacts run a command: gcloud essential-contacts list --organization= 2. Ensure at least one appropriate email address is configured for each of the following notification categories: - `LEGAL` - `SECURITY` - `SUSPENSION` - `TECHNICAL` Alternatively, appropriate email addresses can be configured for the `ALL` notification category to receive all possible important notifications.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/resource-manager/docs/managing-notification-contacts", + "DefaultValue": "By default, there are no Essential Contacts configured. In the absence of an Essential Contact, the following IAM roles are used to identify users to notify for the following categories: - **Legal**: `roles/billing.admin` - **Security**: `roles/resourcemanager.organizationAdmin` - **Suspension**: `roles/owner` - **Technical**: `roles/owner` - **Technical Incidents**: `roles/owner`", + "SubSection": null + } + ] + }, + { + "Id": "1.18", + "Description": "Ensure Secrets are Not Stored in Cloud Functions Environment Variables by Using Secret Manager", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Google Cloud Functions allow you to host serverless code that is executed when an event is triggered, without the requiring the management a host operating system. These functions can also store environment variables to be used by the code that may contain authentication or other information that needs to remain confidential.", + "RationaleStatement": "It is recommended to use the Secret Manager, because environment variables are stored unencrypted, and accessible for all users who have access to the code.", + "ImpactStatement": "There should be no impact on the Cloud Function. There are minor costs after 10,000 requests a month to the Secret Manager API as well for a high use of other functions. Modifying the Cloud Function to use the Secret Manager may prevent it running to completion.", + "RemediationProcedure": "Enable Secret Manager API for your Project **From Google Cloud Console** 1. Within the project you wish to enable, select the Navigation hamburger menu in the top left. Hover over 'APIs & Services' to under the heading 'Serverless', then select 'Enabled APIs & Services' in the menu that opens up. 2. Click the button '+ Enable APIS and Services' 3. In the Search bar, search for 'Secret Manager API' and select it. 4. Click the blue box that says 'Enable'. **From Google Cloud CLI** 1. Within the project you wish to enable the API in, run the following command. gcloud services enable Secret Manager API Reviewing Environment Variables That Should Be Migrated to Secret Manager **From Google Cloud Console** 1. Log in to the Google Cloud Web Portal (https://console.cloud.google.com/) 1. Go to Cloud Functions 1. Click on a function name from the list 1. Click on Edit and review the Runtime environment for variables that should be secrets. Leave this list open for the next step. **From Google Cloud CLI** 1. To view a list of your cloud functions run gcloud functions list 2. For each cloud function run the following command. gcloud functions describe 3. Review the settings of the buildEnvironmentVariables and environmentVariables. Keep this information for the next step. Migrating Environment Variables to Secrets within the Secret Manager **From Google Cloud Console** 1. Go to the Secret Manager page in the Cloud Console. 1. On the Secret Manager page, click Create Secret. 1. On the Create secret page, under Name, enter the name of the Environment Variable you are replacing. This will then be the Secret Variable you will reference in your code. 1. You will also need to add a version. This is the actual value of the variable that will be referenced from the code. To add a secret version when creating the initial secret, in the Secret value field, enter the value from the Environment Variable you are replacing. 1. Leave the Regions section unchanged. 1. Click the Create secret button. 1. Repeat for all Environment Variables **From Google Cloud CLI** 1. Run the following command with the Environment Variable name you are replacing in the ``. It is most secure to point this command to a file with the Environment Variable value located in it, as if you entered it via command line it would show up in your shell’s command history. gcloud secrets create --data-file=/path/to/file.txt Granting your Runtime's Service Account Access to Secrets **From Google Cloud Console** 1. Within the project containing your runtime login with account that has the 'roles/secretmanager.secretAccessor' permission. 2. Select the Navigation hamburger menu in the top left. Hover over 'Security' to under the then select 'Secret Manager' in the menu that opens up. 3. Click the name of a secret listed in this screen. 4. If it is not already open, click Show Info Panel in this screen to open the panel. 5.In the info panel, click Add principal. 6.In the New principals field, enter the service account your function uses for its identity. (If you need help locating or updating your runtime's service account, please see the 'docs/securing/function-identity#runtime_service_account' reference.) 7. In the Select a role dropdown, choose Secret Manager and then Secret Manager Secret Accessor. **From Google Cloud CLI** As of the time of writing, using Google CLI to list Runtime variables is only in beta. Because this is likely to change we are not including it here. Modifying the Code to use the Secrets in Secret Manager **From Google Cloud Console** This depends heavily on which language your runtime is in. For the sake of the brevity of this recommendation, please see the '/docs/creating-and-accessing-secrets#access' reference for language specific instructions. **From Google Cloud CLI** This depends heavily on which language your runtime is in. For the sake of the brevity of this recommendation, please see the' /docs/creating-and-accessing-secrets#access' reference for language specific instructions. Deleting the Insecure Environment Variables **Be certain to do this step last.** Removing variables from code actively referencing them will prevent it from completing successfully. **From Google Cloud Console** 1. Select the Navigation hamburger menu in the top left. Hover over 'Security' then select 'Secret Manager' in the menu that opens up. 1. Click the name of a function. Click Edit. 1. Click Runtime, build and connections settings to expand the advanced configuration options. 1. Click 'Security’. Hover over the secret you want to remove, then click 'Delete'. 1. Click Next. Click Deploy. The latest version of the runtime will now reference the secrets in Secret Manager. **From Google Cloud CLI** gcloud functions deploy --remove-env-vars If you need to find the env vars to remove, they are from the step where ‘gcloud functions describe ``’ was run.", + "AuditProcedure": "Determine if Confidential Information is Stored in your Functions in Cleartext **From Google Cloud Console** 1. Within the project you wish to audit, select the Navigation hamburger menu in the top left. Scroll down to under the heading 'Serverless', then select 'Cloud Functions' 1. Click on a function name from the list 1. Open the Variables tab and you will see both buildEnvironmentVariables and environmentVariables 1. Review the variables whether they are secrets 1. Repeat step 3-5 until all functions are reviewed **From Google Cloud CLI** 1. To view a list of your cloud functions run gcloud functions list 2. For each cloud function in the list run the following command. gcloud functions describe 3. Review the settings of the buildEnvironmentVariables and environmentVariables. Determine if this is data that should not be publicly accessible. Determine if Secret Manager API is 'Enabled' for your Project **From Google Cloud Console** 1. Within the project you wish to audit, select the Navigation hamburger menu in the top left. Hover over 'APIs & Services' to under the heading 'Serverless', then select 'Enabled APIs & Services' in the menu that opens up. 1. Click the button '+ Enable APIS and Services' 1. In the Search bar, search for 'Secret Manager API' and select it. 1. If it is enabled, the blue box that normally says 'Enable' will instead say 'Manage'. **From Google Cloud CLI** 1. Within the project you wish to audit, run the following command. gcloud services list 2. If 'Secret Manager API' is in the list, it is enabled.", + "AdditionalInformation": "There are slight additional costs to using the Secret Manager API. Review the documentation to determine your organizations' needs.", + "References": "https://cloud.google.com/functions/docs/configuring/env-var#managing_secrets:https://cloud.google.com/secret-manager/docs/overview", + "DefaultValue": "By default Secret Manager is not enabled.", + "SubSection": null + } + ] + }, + { + "Id": "2.1", + "Description": "Ensure That Cloud Audit Logging Is Configured Properly", + "Checks": [ + "iam_audit_logs_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that Cloud Audit Logging is configured to track all admin activities and read, write access to user data.", + "RationaleStatement": "Cloud Audit Logging maintains two audit logs for each project, folder, and organization: Admin Activity and Data Access. 1. Admin Activity logs contain log entries for API calls or other administrative actions that modify the configuration or metadata of resources. Admin Activity audit logs are enabled for all services and cannot be configured. 2. Data Access audit logs record API calls that create, modify, or read user-provided data. These are disabled by default and should be enabled. There are three kinds of Data Access audit log information: - Admin read: Records operations that read metadata or configuration information. Admin Activity audit logs record writes of metadata and configuration information that cannot be disabled. - Data read: Records operations that read user-provided data. - Data write: Records operations that write user-provided data. It is recommended to have an effective default audit config configured in such a way that: 1. logtype is set to DATA_READ (to log user activity tracking) and DATA_WRITES (to log changes/tampering to user data). 2. audit config is enabled for all the services supported by the Data Access audit logs feature. 3. Logs should be captured for all users, i.e., there are no exempted users in any of the audit config sections. This will ensure overriding the audit config will not contradict the requirement.", + "ImpactStatement": "There is no charge for Admin Activity audit logs. Enabling the Data Access audit logs might result in your project being charged for the additional logs usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `Audit Logs` by visiting [https://console.cloud.google.com/iam-admin/audit](https://console.cloud.google.com/iam-admin/audit). 2. Follow the steps at [https://cloud.google.com/logging/docs/audit/configure-data-access](https://cloud.google.com/logging/docs/audit/configure-data-access) to enable audit logs for all Google Cloud services. Ensure that no exemptions are allowed. **From Google Cloud CLI** 1. To read the project's IAM policy and store it in a file run a command: gcloud projects get-iam-policy PROJECT_ID > /tmp/project_policy.yaml Alternatively, the policy can be set at the organization or folder level. If setting the policy at the organization level, it is not necessary to also set it for each folder or project. gcloud organizations get-iam-policy ORGANIZATION_ID > /tmp/org_policy.yaml gcloud resource-manager folders get-iam-policy FOLDER_ID > /tmp/folder_policy.yaml 2. Edit policy in /tmp/policy.yaml, adding or changing only the audit logs configuration to: **Note: Admin Activity Logs are enabled by default, and cannot be disabled. So they are not listed in these configuration changes.** auditConfigs: - auditLogConfigs: - logType: DATA_WRITE - logType: DATA_READ service: allServices **Note:** `exemptedMembers:` is not set as audit logging should be enabled for all the users 3. To write new IAM policy run command: gcloud organizations set-iam-policy ORGANIZATION_ID /tmp/org_policy.yaml gcloud resource-manager folders set-iam-policy FOLDER_ID /tmp/folder_policy.yaml gcloud projects set-iam-policy PROJECT_ID /tmp/project_policy.yaml If the preceding command reports a conflict with another change, then repeat these steps, starting with the first step.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Audit Logs` by visiting [https://console.cloud.google.com/iam-admin/audit](https://console.cloud.google.com/iam-admin/audit). 2. Ensure that Admin Read, Data Write, and Data Read are enabled for all Google Cloud services and that no exemptions are allowed. **From Google Cloud CLI** 1. List the Identity and Access Management (IAM) policies for the project, folder, or organization: gcloud organizations get-iam-policy ORGANIZATION_ID gcloud resource-manager folders get-iam-policy FOLDER_ID gcloud projects get-iam-policy PROJECT_ID 2. Policy should have a default auditConfigs section which has the logtype set to DATA_WRITES and DATA_READ for all services. Note that projects inherit settings from folders, which in turn inherit settings from the organization. When called, projects get-iam-policy, the result shows only the policies set in the project, not the policies inherited from the parent folder or organization. Nevertheless, if the parent folder has Cloud Audit Logging enabled, the project does as well. Sample output for default audit configs may look like this: auditConfigs: - auditLogConfigs: - logType: ADMIN_READ - logType: DATA_WRITE - logType: DATA_READ service: allServices 3. Any of the auditConfigs sections should not have parameter exemptedMembers: set, which will ensure that Logging is enabled for all users and no user is exempted.", + "AdditionalInformation": "- Log type `DATA_READ` is equally important to that of `DATA_WRITE` to track detailed user activities. - BigQuery Data Access logs are handled differently from other data access logs. BigQuery logs are enabled by default and cannot be disabled. They do not count against logs allotment and cannot result in extra logs charges.", + "References": "https://cloud.google.com/logging/docs/audit/:https://cloud.google.com/logging/docs/audit/configure-data-access", + "DefaultValue": "Admin Activity logs are always enabled. They cannot be disabled. Data Access audit logs are disabled by default because they can be quite large.", + "SubSection": null + } + ] + }, + { + "Id": "2.2", + "Description": "Ensure Google Workspace/Cloud Identity Data Sharing with Google Cloud is Enabled for Admin Audit Logging", + "Checks": [], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Google Workspace and Cloud Identity maintain audit logs for administrative actions, such as user creation, password changes, and modifications to security settings. By default, these logs are only available within the Google Admin Console. Enabling the \"Sharing of data with Google Cloud Services\" setting allows these logs to flow into the Google Cloud (GCP) Organization's Cloud Logging. This is critical for centralized security monitoring, long-term log retention, and the creation of automated alerts for high-risk identity events like privilege escalation.", + "RationaleStatement": "Without centralized logging of identity provider events, security teams cannot easily correlate Workspace/Identity administrative changes with GCP resource changes. If a Super Admin account is compromised, the audit trail must be preserved in a secure, centralized location to prevent an attacker from deleting evidence within the Workspace console.", + "ImpactStatement": "", + "AuditProcedure": "**From Google Admin Console**\n\n1. Log in to the Google Admin Console as a Super Admin.\n2. From the Home page, go to `Account` > `Account settings`.\n3. Click on the `Legal and compliance` section.\n4. Locate the setting titled `Sharing options - Google Cloud Platform Sharing Options` and verify that the status is set to `Enabled`.", + "RemediationProcedure": "**From Google Admin Console**\n\n1. Log in to the Google Admin Console as a Super Admin.\n2. From the Home page, go to `Account` > `Account settings`.\n3. Click on the `Legal and compliance` section.\n4. Locate the setting titled `Sharing options - Google Cloud Platform Sharing Options`, click on `Enabled` and click `Save`.\n5. (Optional but Recommended) Navigate to the GCP Console > `Logging` > `Logs Explorer` and verify that the logs are arriving from the admin portal.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, Google Workspace/Cloud Identity admin audit logs are only available within the Google Admin Console and are not shared with Google Cloud." + } + ] + }, + { + "Id": "2.3", + "Description": "Ensure That Sinks Are Configured for All Log Entries", + "Checks": [ + "cloudstorage_bucket_log_retention_policy_lock" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to create a sink that will export copies of all the log entries. This can help aggregate logs from multiple projects and export them to a Security Information and Event Management (SIEM).", + "RationaleStatement": "Log entries are held in Cloud Logging. To aggregate logs, export them to a SIEM. To keep them longer, it is recommended to set up a log sink. Exporting involves writing a filter that selects the log entries to export, and choosing a destination in Cloud Storage, BigQuery, or Cloud Pub/Sub. The filter and destination are held in an object called a sink. To ensure all log entries are exported to sinks, ensure that there is no filter configured for a sink. Sinks can be created in projects, organizations, folders, and billing accounts.", + "ImpactStatement": "There are no costs or limitations in Cloud Logging for exporting logs, but the export destinations charge for storing or transmitting the log data.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `Logs Router` by visiting [https://console.cloud.google.com/logs/router](https://console.cloud.google.com/logs/router). 2. Click on the arrow symbol with `CREATE SINK` text. 3. Fill out the fields for `Sink details`. 4. Choose Cloud Logging bucket in the Select sink destination drop down menu. 5. Choose a log bucket in the next drop down menu. 6. If an inclusion filter is not provided for this sink, all ingested logs will be routed to the destination provided above. This may result in higher than expected resource usage. 7. Click `Create Sink`. For more information, see [https://cloud.google.com/logging/docs/export/configure_export_v2#dest-create](https://cloud.google.com/logging/docs/export/configure_export_v2#dest-create). **From Google Cloud CLI** To create a sink to export all log entries in a Google Cloud Storage bucket: gcloud logging sinks create storage.googleapis.com/DESTINATION_BUCKET_NAME Sinks can be created for a folder or organization, which will include all projects. gcloud logging sinks create storage.googleapis.com/DESTINATION_BUCKET_NAME --include-children --folder=FOLDER_ID | --organization=ORGANIZATION_ID **Note:** 1. A sink created by the command-line above will export logs in storage buckets. However, sinks can be configured to export logs into BigQuery, or Cloud Pub/Sub, or `Custom Destination`. 2. While creating a sink, the sink option `--log-filter` is not used to ensure the sink exports all log entries. 3. A sink can be created at a folder or organization level that collects the logs of all the projects underneath bypassing the option `--include-children` in the gcloud command.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Logs Router` by visiting [https://console.cloud.google.com/logs/router](https://console.cloud.google.com/logs/router). 2. For every sink, click the 3-dot button for Menu options and select `View sink details`. 3. Ensure there is at least one sink with an `empty` Inclusion filter. 4. Additionally, ensure that the resource configured as `Destination` exists. **From Google Cloud CLI** 1. Ensure that a sink with an `empty filter` exists. List the sinks for the project, folder or organization. If sinks are configured at a folder or organization level, they do not need to be configured for each project: gcloud logging sinks list --folder=FOLDER_ID | --organization=ORGANIZATION_ID | --project=PROJECT_ID The output should list at least one sink with an `empty filter`. 2. Additionally, ensure that the resource configured as `Destination` exists. See [https://cloud.google.com/sdk/gcloud/reference/beta/logging/sinks/list](https://cloud.google.com/sdk/gcloud/reference/beta/logging/sinks/list) for more information.", + "AdditionalInformation": "For Command-Line Audit and Remediation, the sink destination of type `Cloud Storage Bucket` is considered. However, the destination could be configured to `Cloud Storage Bucket` or `BigQuery` or `Cloud PubSub` or `Custom Destination`. Command Line Interface commands would change accordingly.", + "References": "https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/logging/quotas:https://cloud.google.com/logging/docs/routing/overview:https://cloud.google.com/logging/docs/export/using_exported_logs:https://cloud.google.com/logging/docs/export/configure_export_v2:https://cloud.google.com/logging/docs/export/aggregated_exports:https://cloud.google.com/sdk/gcloud/reference/beta/logging/sinks/list", + "DefaultValue": "By default, there are no sinks configured.", + "SubSection": null + } + ] + }, + { + "Id": "2.4", + "Description": "Ensure That Retention Policies on Cloud Storage Buckets Used for Exporting Logs Are Configured Using Bucket Lock", + "Checks": [ + "cloudstorage_bucket_log_retention_policy_lock" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Enabling retention policies on log buckets will protect logs stored in cloud storage buckets from being overwritten or accidentally deleted. It is recommended to set up retention policies and configure Bucket Lock on all storage buckets that are used as log sinks.", + "RationaleStatement": "Logs can be exported by creating one or more sinks that include a log filter and a destination. As Cloud Logging receives new log entries, they are compared against each sink. If a log entry matches a sink's filter, then a copy of the log entry is written to the destination. Sinks can be configured to export logs in storage buckets. It is recommended to configure a data retention policy for these cloud storage buckets and to lock the data retention policy; thus permanently preventing the policy from being reduced or removed. This way, if the system is ever compromised by an attacker or a malicious insider who wants to cover their tracks, the activity logs are definitely preserved for forensics and security investigations.", + "ImpactStatement": "Locking a bucket is an irreversible action. Once you lock a bucket, you cannot remove the retention policy from the bucket or decrease the retention period for the policy. You will then have to wait for the retention period for all items within the bucket before you can delete them, and then the bucket.", + "RemediationProcedure": "**From Google Cloud Console** 1. If sinks are **not** configured, first follow the instructions in the recommendation: `Ensure that sinks are configured for all Log entries`. 2. For each storage bucket configured as a sink, go to the Cloud Storage browser at `https://console.cloud.google.com/storage/browser/`. 3. Select the Bucket Lock tab near the top of the page. 4. In the Retention policy entry, click the Add Duration link. The `Set a retention policy` dialog box appears. 5. Enter the desired length of time for the retention period and click `Save policy`. 6. Set the `Lock status` for this retention policy to `Locked`. **From Google Cloud CLI** 1. To list all sinks destined to storage buckets: gcloud logging sinks list --folder=FOLDER_ID | --organization=ORGANIZATION_ID | --project=PROJECT_ID 2. For each storage bucket listed above, set a retention policy and lock it: gsutil retention set [TIME_DURATION] gs://[BUCKET_NAME] gsutil retention lock gs://[BUCKET_NAME] For more information, visit [https://cloud.google.com/storage/docs/using-bucket-lock#set-policy](https://cloud.google.com/storage/docs/using-bucket-lock#set-policy).", + "AuditProcedure": "**From Google Cloud Console** 1. Open the Cloud Storage browser in the Google Cloud Console by visiting [https://console.cloud.google.com/storage/browser](https://console.cloud.google.com/storage/browser). 2. In the Column display options menu, make sure `Retention policy` is checked. 3. In the list of buckets, the retention period of each bucket is found in the `Retention policy` column. If the retention policy is locked, an image of a lock appears directly to the left of the retention period. **From Google Cloud CLI** 1. To list all sinks destined to storage buckets: gcloud logging sinks list --folder=FOLDER_ID | --organization=ORGANIZATION_ID | --project=PROJECT_ID 2. For every storage bucket listed above, verify that retention policies and Bucket Lock are enabled: gsutil retention get gs://BUCKET_NAME For more information, see [https://cloud.google.com/storage/docs/using-bucket-lock#view-policy](https://cloud.google.com/storage/docs/using-bucket-lock#view-policy).", + "AdditionalInformation": "Caution: Locking a retention policy is an irreversible action. Once locked, you must delete the entire bucket in order to remove the bucket's retention policy. However, before you can delete the bucket, you must be able to delete all the objects in the bucket, which itself is only possible if all the objects have reached the retention period set by the retention policy.", + "References": "https://cloud.google.com/storage/docs/bucket-lock:https://cloud.google.com/storage/docs/using-bucket-lock:https://cloud.google.com/storage/docs/bucket-lock", + "DefaultValue": "By default, storage buckets used as log sinks do not have retention policies and Bucket Lock configured.", + "SubSection": null + } + ] + }, + { + "Id": "2.5", + "Description": "Ensure Log Metric Filter and Alerts Exist for Project Ownership Assignments/Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "In order to prevent unnecessary project ownership assignments to users/service-accounts and further misuses of projects and resources, all `roles/Owner` assignments should be monitored. Members (users/Service-Accounts) with a role assignment to primitive role `roles/Owner` are project owners. The project owner has all the privileges on the project the role belongs to. These are summarized below: - All viewer permissions on all GCP Services within the project - Permissions for actions that modify the state of all GCP services within the project - Manage roles and permissions for a project and all resources within the project - Set up billing for a project Granting the owner role to a member (user/Service-Account) will allow that member to modify the Identity and Access Management (IAM) policy. Therefore, grant the owner role only if the member has a legitimate purpose to manage the IAM policy. This is because the project IAM policy contains sensitive access control data. Having a minimal set of users allowed to manage IAM policy will simplify any auditing that may be necessary.", + "RationaleStatement": "Project ownership has the highest level of privileges on a project. To avoid misuse of project resources, the project ownership assignment/change actions mentioned above should be monitored and alerted to concerned recipients. - Sending project ownership invites - Acceptance/Rejection of project ownership invite by user - Adding `roleOwner` to a user/service-account - Removing a user/Service account from `roleOwner`", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed log metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 3. Clear any text and add: (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) 4. Click `Submit Filter`. The logs display based on the filter text entered by the user. 5. In the `Metric Editor` menu on the right, fill out the name field. Set `Units` to `1` (default) and the `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the advanced logs query. 6. Click `Create Metric`. **Create the display prescribed Alert Policy:** 1. Identify the newly created metric under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the desired metric and select `Create alert from Metric`. A new page opens. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of zero(0) for the most recent value will ensure that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notifications channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create a prescribed Log Metric: - Use the command: gcloud beta logging metrics create - Reference for Command Usage: https://cloud.google.com/sdk/gcloud/reference/beta/logging/metrics/create Create prescribed Alert Policy - Use the command: gcloud alpha monitoring policies create - Reference for Command Usage: https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create", + "AuditProcedure": "**From Google Cloud Console** **Ensure that the prescribed log metric is present:** 1. Go to `Logging/Log-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure that at least one metric `` is present with filter text: (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) **Ensure that the prescribed Alerting Policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of zero(0) for greater than zero(0) seconds` means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for your organization. 5. Ensure that the appropriate notifications channels have been set up. **From Google Cloud CLI** **Ensure that the prescribed log metric is present:** 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with filter set to: (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) 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure that the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains an least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "1. Project ownership assignments for a user cannot be done using the gcloud utility as assigning project ownership to a user requires sending, and the user accepting, an invitation. 2. Project Ownership assignment to a service account does not send any invites. SetIAMPolicy to `role/owner`is directly performed on service accounts.", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "2.6", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for Audit Configuration Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Google Cloud Platform (GCP) services write audit log entries to the Admin Activity and Data Access logs to help answer the questions of, who did what, where, and when? within GCP projects. Cloud audit logging records information includes the identity of the API caller, the time of the API call, the source IP address of the API caller, the request parameters, and the response elements returned by GCP services. Cloud audit logging provides a history of GCP API calls for an account, including API calls made via the console, SDKs, command-line tools, and other GCP services.", + "RationaleStatement": "Admin activity and data access logs produced by cloud audit logging enable security analysis, resource change tracking, and compliance auditing. Configuring the metric filter and alerts for audit configuration changes ensures the recommended state of audit configuration is maintained so that all activities in the project are audit-able at any point in time.", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed log metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 3. Clear any text and add: protoPayload.methodName=SetIamPolicy AND protoPayload.serviceData.policyDelta.auditConfigDeltas:* 4. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 5. In the `Metric Editor` menu on the right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This will ensure that the log metric counts the number of log entries matching the user's advanced logs query. 6. Click `Create Metric`. **Create a prescribed Alert Policy:** 1. Identify the new metric the user just created, under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the new metric and select `Create alert from Metric`. A new page opens. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the organization. For example, a threshold of zero(0) for the most recent value will ensure that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notifications channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create a prescribed Log Metric: - Use the command: gcloud beta logging metrics create - Reference for command usage: [https://cloud.google.com/sdk/gcloud/reference/beta/logging/metrics/create ](https://cloud.google.com/sdk/gcloud/reference/beta/logging/metrics/create) Create prescribed Alert Policy - Use the command: gcloud alpha monitoring policies create - Reference for command usage: [https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create](https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create)", + "AuditProcedure": "**From Google Cloud Console** **Ensure the prescribed log metric is present:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure that at least one metric `` is present with the filter text: protoPayload.methodName=SetIamPolicy AND protoPayload.serviceData.policyDelta.auditConfigDeltas:* **Ensure that the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of 0 for greater than zero(0) seconds`, means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for the user's organization. 5. Ensure that appropriate notifications channels have been set up. **From Google Cloud CLI** **Ensure that the prescribed log metric is present:** 1. List the log metrics: gcloud beta logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to: protoPayload.methodName=SetIamPolicy AND protoPayload.serviceData.policyDelta.auditConfigDeltas:* 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure that the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains at least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/logging/docs/audit/configure-data-access#getiampolicy-setiampolicy", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "2.7", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for Custom Role Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that a metric filter and alarm be established for changes to Identity and Access Management (IAM) role creation, deletion and updating activities.", + "RationaleStatement": "Google Cloud IAM provides predefined roles that give granular access to specific Google Cloud Platform resources and prevent unwanted access to other resources. However, to cater to organization-specific needs, Cloud IAM also provides the ability to create custom roles. Project owners and administrators with the Organization Role Administrator role or the IAM Role Administrator role can create custom roles. Monitoring role creation, deletion and updating activities will help in identifying any over-privileged role at early stages.", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage.", + "RemediationProcedure": "**From Console:** **Create the prescribed log metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 1. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 1. Clear any text and add: 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 OR protoPayload.methodName=google.iam.admin.v1.UndeleteRole) 1. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 1. In the `Metric Editor` menu on the right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the advanced logs query. 1. Click `Create Metric`. **Create a prescribed Alert Policy:** 1. Identify the new metric that was just created under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the metric and select `Create alert from Metric`. A new page displays. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of zero(0) for the most recent value ensures that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 1. Configure the desired notification channels in the section `Notifications`. 1. Name the policy and click `Save`. **From Google Cloud CLI** Create the prescribed Log Metric: - Use the command: gcloud logging metrics create Create the prescribed Alert Policy: - Use the command: gcloud alpha monitoring policies create ", + "AuditProcedure": "**From Console:** **Ensure that the prescribed log metric is present:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure that at least one metric `` is present with filter text: 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 OR protoPayload.methodName=google.iam.admin.v1.UndeleteRole) **Ensure that the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of zero(0) for greater than zero(0) seconds` means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for the user's organization. 5. Ensure that the appropriate notifications channels have been set up. **From Google Cloud CLI** Ensure that the prescribed log metric is present: 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to: 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 OR protoPayload.methodName=google.iam.admin.v1.UndeleteRole) 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure that the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains an least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/iam/docs/understanding-custom-roles", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "2.8", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for VPC Network Firewall Rule Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that a metric filter and alarm be established for Virtual Private Cloud (VPC) Network Firewall rule changes.", + "RationaleStatement": "Monitoring for Create or Update Firewall rule events gives insight to network access changes and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage. These charges could be significant depending on the size of the organization.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed log metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 3. Clear any text and add: resource.type=gce_firewall_rule AND (protoPayload.methodName:compute.firewalls.patch OR protoPayload.methodName:compute.firewalls.insert OR protoPayload.methodName:compute.firewalls.delete) 4. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 5. In the `Metric Editor` menu on the right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the advanced logs query. 6. Click `Create Metric`. **Create the prescribed Alert Policy:** 1. Identify the newly created metric under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the new metric and select `Create alert from Metric`. A new page displays. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of zero(0) for the most recent value ensures that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notifications channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create the prescribed Log Metric - Use the command: gcloud logging metrics create Create the prescribed alert policy: - Use the command: gcloud alpha monitoring policies create", + "AuditProcedure": "**From Google Cloud Console** **Ensure that the prescribed log metric is present:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure at least one metric `` is present with this filter text: resource.type=gce_firewall_rule AND (protoPayload.methodName:compute.firewalls.patch OR protoPayload.methodName:compute.firewalls.insert OR protoPayload.methodName:compute.firewalls.delete) **Ensure that the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of zero(0) for greater than zero(0) seconds` means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for the user's organization. 5. Ensure that appropriate notification channels have been set up. **From Google Cloud CLI** **Ensure that the prescribed log metric is present:** 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to: resource.type=gce_firewall_rule AND (protoPayload.methodName:compute.firewalls.patch OR protoPayload.methodName:compute.firewalls.insert OR protoPayload.methodName:compute.firewalls.delete) 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure that the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains an least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/vpc/docs/firewalls", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "2.9", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for VPC Network Route Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that a metric filter and alarm be established for Virtual Private Cloud (VPC) network route changes.", + "RationaleStatement": "Google Cloud Platform (GCP) routes define the paths network traffic takes from a VM instance to another destination. The other destination can be inside the organization VPC network (such as another VM) or outside of it. Every route consists of a destination and a next hop. Traffic whose destination IP is within the destination range is sent to the next hop for delivery. Monitoring changes to route tables will help ensure that all VPC traffic flows through an expected path.", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage. These charges could be significant depending on the size of the organization.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed Log Metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter` 3. Clear any text and add: resource.type=gce_route AND (protoPayload.methodName:compute.routes.delete OR protoPayload.methodName:compute.routes.insert) 4. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 5. In the `Metric Editor` menu on the right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the user's advanced logs query. 6. Click `Create Metric`. **Create the prescribed alert policy:** 1. Identify the newly created metric under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the new metric and select `Create alert from Metric`. A new page displays. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of zero(0) for the most recent value ensures that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notification channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create the prescribed Log Metric: - Use the command: gcloud logging metrics create Create the prescribed the alert policy: - Use the command: gcloud alpha monitoring policies create", + "AuditProcedure": "**From Google Cloud Console** **Ensure that the prescribed Log metric is present:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure that at least one metric `` is present with the filter text: resource.type=gce_route AND (protoPayload.methodName:compute.routes.delete OR protoPayload.methodName:compute.routes.insert) **Ensure the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting: [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of 0 for greater than zero(0) seconds` means that the alert will trigger for any new owner change. Verify that the chosen alert thresholds make sense for the user's organization. 5. Ensure that the appropriate notification channels have been set up. **From Google Cloud CLI** **Ensure the prescribed log metric is present:** 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to: resource.type=gce_route AND (protoPayload.methodName:compute.routes.delete OR protoPayload.methodName:compute.routes.insert) 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure that the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains an least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/storage/docs/access-control/iam:https://cloud.google.com/sdk/gcloud/reference/beta/logging/metrics/create:https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "2.10", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for VPC Network Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that a metric filter and alarm be established for Virtual Private Cloud (VPC) network changes.", + "RationaleStatement": "It is possible to have more than one VPC within a project. In addition, it is also possible to create a peer connection between two VPCs enabling network traffic to route between VPCs. Monitoring changes to a VPC will help ensure VPC traffic flow is not getting impacted.", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage. These charges could be significant depending on the size of the organization.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed log metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 3. Clear any text and add: 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) 4. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 5. In the `Metric Editor` menu on the right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the user's advanced logs query. 6. Click `Create Metric`. **Create the prescribed alert policy:** 1. Identify the newly created metric under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the new metric and select `Create alert from Metric`. A new page appears. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of 0 for the most recent value will ensure that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notification channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create the prescribed Log Metric: - Use the command: gcloud logging metrics create Create the prescribed alert policy: - Use the command: gcloud alpha monitoring policies create", + "AuditProcedure": "**From Google Cloud Console** **Ensure the prescribed log metric is present:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure at least one metric `` is present with filter text: 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) **Ensure the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of 0 for greater than 0 seconds` means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for the user's organization. 5. Ensure that appropriate notification channels have been set up. **From Google Cloud CLI** **Ensure the log metric is present:** 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with filter set to: resource.type=gce_network AND protoPayload.methodName=beta.compute.networks.insert OR protoPayload.methodName=beta.compute.networks.patch OR protoPayload.methodName=v1.compute.networks.delete OR protoPayload.methodName=v1.compute.networks.removePeering OR protoPayload.methodName=v1.compute.networks.addPeering 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains at least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/vpc/docs/overview", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "2.11", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for Cloud Storage IAM Permission Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that a metric filter and alarm be established for Cloud Storage Bucket IAM changes.", + "RationaleStatement": "Monitoring changes to cloud storage bucket permissions may reduce the time needed to detect and correct permissions on sensitive cloud storage buckets and objects inside the bucket.", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage. These charges could be significant depending on the size of the organization.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed log metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 3. Clear any text and add: resource.type=gcs_bucket AND protoPayload.methodName=storage.setIamPermissions 4. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 5. In the `Metric Editor` menu on right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the user's advanced logs query. 6. Click `Create Metric`. **Create the prescribed Alert Policy:** 1. Identify the newly created metric under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the new metric and select `Create alert from Metric`. A new page appears. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of zero(0) for the most recent value will ensure that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notifications channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create the prescribed Log Metric: - Use the command: gcloud beta logging metrics create Create the prescribed alert policy: - Use the command: gcloud alpha monitoring policies create", + "AuditProcedure": "**From Google Cloud Console** **Ensure the prescribed log metric is present:** 1. For each project that contains cloud storage buckets, go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure at least one metric `` is present with the filter text: resource.type=gcs_bucket AND protoPayload.methodName=storage.setIamPermissions **Ensure that the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of 0 for greater than 0 seconds` means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for the user's organization. 5. Ensure that the appropriate notifications channels have been set up. **From Google Cloud CLI** **Ensure that the prescribed log metric is present:** 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to: resource.type=gcs_bucket AND protoPayload.methodName=storage.setIamPermissions 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains an least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/storage/docs/overview:https://cloud.google.com/storage/docs/access-control/iam-roles", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "2.12", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for SQL Instance Configuration Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that a metric filter and alarm be established for SQL instance configuration changes.", + "RationaleStatement": "Monitoring changes to SQL instance configuration changes may reduce the time needed to detect and correct misconfigurations done on the SQL server. Below are a few of the configurable options which may the impact security posture of an SQL instance: - Enable auto backups and high availability: Misconfiguration may adversely impact business continuity, disaster recovery, and high availability - Authorize networks: Misconfiguration may increase exposure to untrusted networks", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage. These charges could be significant depending on the size of the organization.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed Log Metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 3. Clear any text and add: protoPayload.methodName=cloudsql.instances.update 4. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 5. In the `Metric Editor` menu on right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the user's advanced logs query. 6. Click `Create Metric`. **Create the prescribed alert policy:** 1. Identify the newly created metric under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the new metric and select `Create alert from Metric`. A new page appears. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of zero(0) for the most recent value will ensure that a notification is triggered for every owner change in the user's project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notification channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create the prescribed log metric: - Use the command: gcloud logging metrics create Create the prescribed alert policy: - Use the command: gcloud alpha monitoring policies create - Reference for command usage: [https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create](https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create)", + "AuditProcedure": "**From Google Cloud Console** **Ensure the prescribed log metric is present:** 1. For each project that contains Cloud SQL instances, go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure that at least one metric `` is present with the filter text: protoPayload.methodName=cloudsql.instances.update **Ensure that the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of zero(0) for greater than zero(0) seconds` means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for the user's organization. 5. Ensure that the appropriate notifications channels have been set up. **From Google Cloud CLI** **Ensure that the prescribed log metric is present:** 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to protoPayload.methodName=cloudsql.instances.update 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure that the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains at least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/storage/docs/overview:https://cloud.google.com/sql/docs/:https://cloud.google.com/sql/docs/mysql/:https://cloud.google.com/sql/docs/postgres/", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "2.13", + "Description": "Ensure That Cloud DNS Logging Is Enabled for All VPC Networks", + "Checks": [], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Cloud DNS logging records the queries from the name servers within your VPC to Stackdriver. Logged queries can come from Compute Engine VMs, GKE containers, or other GCP resources provisioned within the VPC.", + "RationaleStatement": "Security monitoring and forensics cannot depend solely on IP addresses from VPC flow logs, especially when considering the dynamic IP usage of cloud resources, HTTP virtual host routing, and other technology that can obscure the DNS name used by a client from the IP address. Monitoring of Cloud DNS logs provides visibility to DNS names requested by the clients within the VPC. These logs can be monitored for anomalous domain names, evaluated against threat intelligence, and Note: For full capture of DNS, firewall must block egress UDP/53 (DNS) and TCP/443 (DNS over HTTPS) to prevent client from using external DNS name server for resolution.", + "ImpactStatement": "Enabling of Cloud DNS logging might result in your project being charged for the additional logs usage.", + "RemediationProcedure": "**From Google Cloud CLI** **Add New DNS Policy With Logging Enabled** For each VPC network that needs a DNS policy with logging enabled: gcloud dns policies create enable-dns-logging --enable-logging --description=Enable DNS Logging --networks=VPC_NETWORK_NAME The VPC_NETWORK_NAME can be one or more networks in comma-separated list **Enable Logging for Existing DNS Policy** For each VPC network that has an existing DNS policy that needs logging enabled: gcloud dns policies update POLICY_NAME --enable-logging --networks=VPC_NETWORK_NAME The VPC_NETWORK_NAME can be one or more networks in comma-separated list", + "AuditProcedure": "**From Google Cloud CLI** 1. List all VPCs networks in a project: gcloud compute networks list --format=table[box,title='All VPC Networks'](name:label='VPC Network Name') 2. List all DNS policies, logging enablement, and associated VPC networks: gcloud dns policies list --flatten=networks[] --format=table[box,title='All DNS Policies By VPC Network'](name:label='Policy Name',enableLogging:label='Logging Enabled':align=center,networks.networkUrl.basename():label='VPC Network Name') Each VPC Network should be associated with a DNS policy with logging enabled.", + "AdditionalInformation": "Additional Info - Only queries that reach a name server are logged. Cloud DNS resolvers cache responses, queries answered from caches, or direct queries to an external DNS resolver outside the VPC are not logged.", + "References": "https://cloud.google.com/dns/docs/monitoring", + "DefaultValue": "Cloud DNS logging is disabled by default on each network.", + "SubSection": null + } + ] + }, + { + "Id": "2.14", + "Description": "Ensure Cloud Asset Inventory Is Enabled", + "Checks": [ + "iam_cloud_asset_inventory_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "GCP Cloud Asset Inventory is services that provides a historical view of GCP resources and IAM policies through a time-series database. The information recorded includes metadata on Google Cloud resources, metadata on policies set on Google Cloud projects or resources, and runtime information gathered within a Google Cloud resource. Cloud Asset Inventory Service (CAIS) API enablement is not required for operation of the service, but rather enables the mechanism for searching/exporting CAIS asset data directly.", + "RationaleStatement": "The GCP resources and IAM policies captured by GCP Cloud Asset Inventory enables security analysis, resource change tracking, and compliance auditing. It is recommended GCP Cloud Asset Inventory be enabled for all GCP projects.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud Console** Enable the Cloud Asset API: 1. Go to `API & Services/Library` by visiting [https://console.cloud.google.com/apis/library](https://console.cloud.google.com/apis/library) 2. Search for `Cloud Asset API` and select the result for _Cloud Asset API_ 3. Click the `ENABLE` button. **From Google Cloud CLI** Enable the Cloud Asset API: 1. Enable the Cloud Asset API through the services interface: gcloud services enable cloudasset.googleapis.com ", + "AuditProcedure": "**From Google Cloud Console** Ensure that the Cloud Asset API is enabled: 1. Go to `API & Services/Library` by visiting [https://console.cloud.google.com/apis/library](https://console.cloud.google.com/apis/library) 2. Search for `Cloud Asset API` and select the result for _Cloud Asset API_ 3. Ensure that `API Enabled` is displayed. **From Google Cloud CLI** Ensure that the Cloud Asset API is enabled: 1. Query enabled services: gcloud services list --enabled --filter=name:cloudasset.googleapis.com If the API is listed, then it is enabled. If the response is `Listed 0 items` the API is not enabled.", + "AdditionalInformation": "Additional info - Cloud Asset Inventory only keeps a five-week history of Google Cloud asset metadata. If a longer history is desired, automation to export the history to Cloud Storage or BigQuery should be evaluated. Users need not enable CAI API if they don't have any plans to export.", + "References": "https://cloud.google.com/asset-inventory/docs", + "DefaultValue": "The Cloud Asset Inventory API is disabled by default in each project.", + "SubSection": null + } + ] + }, + { + "Id": "2.15", + "Description": "Ensure 'Access Transparency' is 'Enabled'", + "Checks": [], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "GCP Access Transparency provides audit logs for all actions that Google personnel take in your Google Cloud resources.", + "RationaleStatement": "Controlling access to your information is one of the foundations of information security. Given that Google Employees do have access to your organizations' projects for support reasons, you should have logging in place to view who, when, and why your information is being accessed.", + "ImpactStatement": "To use Access Transparency your organization will need to have at one of the following support level: Premium, Enterprise, Platinum, or Gold. There will be subscription costs associated with support, as well as increased storage costs for storing the logs. You will also not be able to turn Access Transparency off yourself, and you will need to submit a service request to Google Cloud Support.", + "RemediationProcedure": "**From Google Cloud Console** **Add privileges to enable Access Transparency** 1. From the Google Cloud Home, within the project you wish to check, click on the Navigation hamburger menu in the top left. Hover over the 'IAM and Admin'. Select `IAM` in the top of the column that opens. 2. Click the blue button the says `+add` at the top of the screen. 3. In the `principals` field, select a user or group by typing in their associated email address. 4. Click on the `role` field to expand it. In the filter field enter `Access Transparency Admin` and select it. 5. Click `save`. **Verify that the Google Cloud project is associated with a billing account** 1. From the Google Cloud Home, click on the Navigation hamburger menu in the top left. Select `Billing`. 2. If you see `This project is not associated with a billing account` you will need to enter billing information or switch to a project with a billing account. **Enable Access Transparency** 1. From the Google Cloud Home, click on the Navigation hamburger menu in the top left. Hover over the IAM & Admin Menu. Select `settings` in the middle of the column that opens. 2. Click the blue button labeled Enable `Access Transparency for Organization`", + "AuditProcedure": "**From Google Cloud Console** **Determine if Access Transparency is Enabled** 1. From the Google Cloud Home, click on the Navigation hamburger menu in the top left. Hover over the IAM & Admin Menu. Select `settings` in the middle of the column that opens. 2. The status will be under the heading `Access Transparency`. Status should be `Enabled`", + "AdditionalInformation": "To enable Access Transparency for your Google Cloud organization, your Google Cloud organization must have one of the following customer support levels: Premium, Enterprise, Platinum, or Gold.", + "References": "https://cloud.google.com/cloud-provider-access-management/access-transparency/docs/overview:https://cloud.google.com/cloud-provider-access-management/access-transparency/docs/enable:https://cloud.google.com/cloud-provider-access-management/access-transparency/docs/reading-logs:https://cloud.google.com/cloud-provider-access-management/access-transparency/docs/reading-logs#justification_reason_codes:https://cloud.google.com/cloud-provider-access-management/access-transparency/docs/supported-services", + "DefaultValue": "By default Access Transparency is not enabled.", + "SubSection": null + } + ] + }, + { + "Id": "2.16", + "Description": "Ensure 'Access Approval' is 'Enabled'", + "Checks": [ + "iam_account_access_approval_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "GCP Access Approval enables you to require your organizations' explicit approval whenever Google support try to access your projects. You can then select users within your organization who can approve these requests through giving them a security role in IAM. All access requests display which Google Employee requested them in an email or Pub/Sub message that you can choose to Approve. This adds an additional control and logging of who in your organization approved/denied these requests.", + "RationaleStatement": "Controlling access to your information is one of the foundations of information security. Google Employees do have access to your organizations' projects for support reasons. With Access Approval, organizations can then be certain that their information is accessed by only approved Google Personnel.", + "ImpactStatement": "To use Access Approval your organization will need have enabled Access Transparency and have at one of the following support level: Enhanced or Premium. There will be subscription costs associated with these support levels, as well as increased storage costs for storing the logs. You will also not be able to turn the Access Transparency which Access Approval depends on, off yourself. To do so you will need to submit a service request to Google Cloud Support. There will also be additional overhead in managing user permissions. There may also be a potential delay in support times as Google Personnel will have to wait for their access to be approved.", + "RemediationProcedure": "**From Google Cloud Console** 1. From the Google Cloud Home, within the project you wish to enable, click on the Navigation hamburger menu in the top left. Hover over the `Security` Menu. Select `Access Approval` in the middle of the column that opens. 2. The status will be displayed here. On this screen, there is an option to click `Enroll`. If it is greyed out and you see an error bar at the top of the screen that says `Access Transparency is not enabled` please view the corresponding reference within this section to enable it. 3. In the second screen click `Enroll`. **Grant an IAM Group or User the role with permissions to Add Users to be Access Approval message Recipients** 1. From the Google Cloud Home, within the project you wish to enable, click on the Navigation hamburger menu in the top left. Hover over the `IAM and Admin`. Select `IAM` in the middle of the column that opens. 2. Click the blue button the says `+ ADD` at the top of the screen. 3. In the `principals` field, select a user or group by typing in their associated email address. 4. Click on the role field to expand it. In the filter field enter `Access Approval Approver` and select it. 5. Click `save`. **Add a Group or User as an Approver for Access Approval Requests** 1. As a user with the `Access Approval Approver` permission, within the project where you wish to add an email address to which request will be sent, click on the Navigation hamburger menu in the top left. Hover over the `Security` Menu. Select `Access Approval` in the middle of the column that opens. 2. Click `Manage Settings` 3. Under `Set up approval notifications`, enter the email address associated with a Google Cloud User or Group you wish to send Access Approval requests to. All future access approvals will be sent as emails to this address. **From Google Cloud CLI** 1. To update all services in an entire project, run the following command from an account that has permissions as an 'Approver for Access Approval Requests' gcloud access-approval settings update --project= --enrolled_services=all --notification_emails='@' ", + "AuditProcedure": "**From Google Cloud Console** **Determine if Access Transparency is Enabled as it is a Dependency** 1. From the Google Cloud Home inside the project you wish to audit, click on the Navigation hamburger menu in the top left. Hover over the `IAM & Admin` Menu. Select `settings` in the middle of the column that opens. 2. The status should be Enabled' under the heading `Access Transparency` **Determine if Access Approval is Enabled** 1. From the Google Cloud Home, within the project you wish to check, click on the Navigation hamburger menu in the top left. Hover over the `Security` Menu. Select `Access Approval` in the middle of the column that opens. 2. The status will be displayed here. If you see a screen saying you need to enroll in Access Approval, it is not enabled. **From Google Cloud CLI** **Determine if Access Approval is Enabled** 1. From within the project you wish to audit, run the following command. gcloud access-approval settings get 2. The status will be displayed in the output. IF Access Approval is not enabled you should get this output: API [accessapproval.googleapis.com] not enabled on project [-----]. Would you like to enable and retry (this will take a few minutes)? (y/N)? After entering `Y` if you get the following output, it means that `Access Transparency` is not enabled: ERROR: (gcloud.access-approval.settings.get) FAILED_PRECONDITION: Precondition check failed. ", + "AdditionalInformation": "The recipients of Access Requests will also need to be logged into a Google Cloud account associated with an email address in this list. To approve requests they can click approve within the email. Or they can view requests at the the Access Approval page within the Security submenu.", + "References": "https://cloud.google.com/cloud-provider-access-management/access-approval/docs:https://cloud.google.com/cloud-provider-access-management/access-approval/docs/overview:https://cloud.google.com/cloud-provider-access-management/access-approval/docs/quickstart-custom-key:https://cloud.google.com/cloud-provider-access-management/access-approval/docs/supported-services:https://cloud.google.com/cloud-provider-access-management/access-approval/docs/view-historical-requests", + "DefaultValue": "By default Access Approval and its dependency of Access Transparency are not enabled.", + "SubSection": null + } + ] + }, + { + "Id": "2.17", + "Description": "Ensure Logging is enabled for HTTP(S) Load Balancer", + "Checks": [ + "compute_loadbalancer_logging_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Logging enabled on a HTTPS Load Balancer will show all network traffic and its destination.", + "RationaleStatement": "Logging will allow you to view HTTPS network traffic to your web applications.", + "ImpactStatement": "On high use systems with a high percentage sample rate, the logging file may grow to high capacity in a short amount of time. Ensure that the sample rate is set appropriately so that storage costs are not exorbitant.", + "RemediationProcedure": "**From Google Cloud Console** 1. From Google Cloud home open the Navigation Menu in the top left. 1. Under the `Networking` heading select `Network services`. 1. Select the HTTPS load-balancer you wish to audit. 1. Select `Edit` then `Backend Configuration`. 1. Select `Edit` on the corresponding backend service. 1. Click `Enable Logging`. 1. Set `Sample Rate` to a desired value. This is a percentage as a decimal point. 1.0 is 100%. **From Google Cloud CLI** 1. Run the following command gcloud compute backend-services update --region=REGION --enable-logging --logging-sample-rate= ", + "AuditProcedure": "**From Google Cloud Console** 1. From Google Cloud home open the Navigation Menu in the top left. 1. Under the `Networking` heading select `Network services`. 1. Select the HTTPS load-balancer you wish to audit. 1. Select `Edit` then `Backend Configuration`. 1. Select `Edit` on the corresponding backend service. 1. Ensure that `Enable Logging` is selected. Also ensure that `Sample Rate` is set to an appropriate level for your needs. **From Google Cloud CLI** 1. Run the following command gcloud compute backend-services describe 1. Ensure that enable-logging is enabled and sample rate is set to your desired level.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/load-balancing/:https://cloud.google.com/load-balancing/docs/https/https-logging-monitoring#gcloud:-global-mode:https://cloud.google.com/sdk/gcloud/reference/compute/backend-services/", + "DefaultValue": "By default logging for https load balancing is disabled. When logging is enabled it sets the default sample rate as 1.0 or 100%. Ensure this value fits the need of your organization to avoid high storage costs.", + "SubSection": null + } + ] + }, + { + "Id": "3.1", + "Description": "Ensure That the Default Network Does Not Exist in a Project", + "Checks": [ + "compute_network_default_in_use" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "To prevent use of `default` network, a project should not have a `default` network.", + "RationaleStatement": "The `default` network has a preconfigured network configuration and automatically generates the following insecure firewall rules: - default-allow-internal: Allows ingress connections for all protocols and ports among instances in the network. - default-allow-ssh: Allows ingress connections on TCP port 22(SSH) from any source to any instance in the network. - default-allow-rdp: Allows ingress connections on TCP port 3389(RDP) from any source to any instance in the network. - default-allow-icmp: Allows ingress ICMP traffic from any source to any instance in the network. These automatically created firewall rules do not get audit logged by default. Furthermore, the default network is an auto mode network, which means that its subnets use the same predefined range of IP addresses, and as a result, it's not possible to use Cloud VPN or VPC Network Peering with the default network. Based on organization security and networking requirements, the organization should create a new network and delete the `default` network.", + "ImpactStatement": "When an organization deletes the default network, it will need to remove all asests from that network and migrate them to a new network.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the `VPC networks` page by visiting: [https://console.cloud.google.com/networking/networks/list](https://console.cloud.google.com/networking/networks/list). 2. Click the network named `default`. 2. On the network detail page, click `EDIT`. 3. Click `DELETE VPC NETWORK`. 4. If needed, create a new network to replace the default network. **From Google Cloud CLI** For each Google Cloud Platform project, 1. Delete the default network: gcloud compute networks delete default 2. If needed, create a new network to replace it: gcloud compute networks create NETWORK_NAME **Prevention:** The user can prevent the default network and its insecure default firewall rules from being created by setting up an Organization Policy to `Skip default network creation` at [https://console.cloud.google.com/iam-admin/orgpolicies/compute-skipDefaultNetworkCreation](https://console.cloud.google.com/iam-admin/orgpolicies/compute-skipDefaultNetworkCreation).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VPC networks` page by visiting: [https://console.cloud.google.com/networking/networks/list](https://console.cloud.google.com/networking/networks/list). 2. Ensure that a network with the name `default` is not present. **From Google Cloud CLI** 1. Set the project name in the Google Cloud Shell: gcloud config set project PROJECT_ID 2. List the networks configured in that project: gcloud compute networks list It should not list `default` as one of the available networks in that project.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/compute/docs/networking#firewall_rules:https://cloud.google.com/compute/docs/reference/latest/networks/insert:https://cloud.google.com/compute/docs/reference/latest/networks/delete:https://cloud.google.com/vpc/docs/firewall-rules-logging:https://cloud.google.com/vpc/docs/vpc#default-network:https://cloud.google.com/sdk/gcloud/reference/compute/networks/delete", + "DefaultValue": "By default, for each project, a `default` network is created.", + "SubSection": null + } + ] + }, + { + "Id": "3.2", + "Description": "Ensure Legacy Networks Do Not Exist for Older Projects", + "Checks": [ + "compute_network_not_legacy" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "In order to prevent use of legacy networks, a project should not have a legacy network configured. As of now, Legacy Networks are gradually being phased out, and you can no longer create projects with them. This recommendation is to check older projects to ensure that they are not using Legacy Networks.", + "RationaleStatement": "Legacy networks have a single network IPv4 prefix range and a single gateway IP address for the whole network. The network is global in scope and spans all cloud regions. Subnetworks cannot be created in a legacy network and are unable to switch from legacy to auto or custom subnet networks. Legacy networks can have an impact for high network traffic projects and are subject to a single point of contention or failure.", + "ImpactStatement": "None.", + "RemediationProcedure": "**From Google Cloud CLI** For each Google Cloud Platform project, 1. Follow the documentation and create a non-legacy network suitable for the organization's requirements. 2. Follow the documentation and delete the networks in the `legacy` mode.", + "AuditProcedure": "**From Google Cloud CLI** For each Google Cloud Platform project, 1. Set the project name in the Google Cloud Shell: gcloud config set project 2. List the networks configured in that project: gcloud compute networks list None of the listed networks should be in the `legacy` mode.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/vpc/docs/using-legacy#creating_a_legacy_network:https://cloud.google.com/vpc/docs/using-legacy#deleting_a_legacy_network", + "DefaultValue": "By default, networks are not created in the `legacy` mode.", + "SubSection": null + } + ] + }, + { + "Id": "3.3", + "Description": "Ensure That DNSSEC Is Enabled for Cloud DNS", + "Checks": [ + "dns_dnssec_disabled" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Cloud Domain Name System (DNS) is a fast, reliable and cost-effective domain name system that powers millions of domains on the internet. Domain Name System Security Extensions (DNSSEC) in Cloud DNS enables domain owners to take easy steps to protect their domains against DNS hijacking and man-in-the-middle and other attacks.", + "RationaleStatement": "Domain Name System Security Extensions (DNSSEC) adds security to the DNS protocol by enabling DNS responses to be validated. Having a trustworthy DNS that translates a domain name like www.example.com into its associated IP address is an increasingly important building block of today’s web-based applications. Attackers can hijack this process of domain/IP lookup and redirect users to a malicious site through DNS hijacking and man-in-the-middle attacks. DNSSEC helps mitigate the risk of such attacks by cryptographically signing DNS records. As a result, it prevents attackers from issuing fake DNS responses that may misdirect browsers to nefarious websites.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `Cloud DNS` by visiting [https://console.cloud.google.com/net-services/dns/zones](https://console.cloud.google.com/net-services/dns/zones). 2. For each zone of `Type` `Public`, set `DNSSEC` to `On`. **From Google Cloud CLI** Use the below command to enable `DNSSEC` for Cloud DNS Zone Name. gcloud dns managed-zones update ZONE_NAME --dnssec-state on ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Cloud DNS` by visiting [https://console.cloud.google.com/net-services/dns/zones](https://console.cloud.google.com/net-services/dns/zones). 2. For each zone of `Type` `Public`, ensure that `DNSSEC` is set to `On`. **From Google Cloud CLI** 1. List all the Managed Zones in a project: gcloud dns managed-zones list 2. For each zone of `VISIBILITY` `public`, get its metadata: gcloud dns managed-zones describe ZONE_NAME 3. Ensure that `dnssecConfig.state` property is `on`.", + "AdditionalInformation": "", + "References": "https://cloudplatform.googleblog.com/2017/11/DNSSEC-now-available-in-Cloud-DNS.html:https://cloud.google.com/dns/dnssec-config#enabling:https://cloud.google.com/dns/dnssec", + "DefaultValue": "By default DNSSEC is not enabled.", + "SubSection": null + } + ] + }, + { + "Id": "3.4", + "Description": "Ensure That RSASHA1 Is Not Used for the Key-Signing Key in Cloud DNS DNSSEC", + "Checks": [ + "dns_rsasha1_in_use_to_key_sign_in_dnssec" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "NOTE: Currently, the SHA1 algorithm has been removed from general use by Google, and, if being used, needs to be whitelisted on a project basis by Google and will also, therefore, require a Google Cloud support contract. DNSSEC algorithm numbers in this registry may be used in CERT RRs. Zone signing (DNSSEC) and transaction security mechanisms (SIG(0) and TSIG) make use of particular subsets of these algorithms. The algorithm used for key signing should be a recommended one and it should be strong.", + "RationaleStatement": "Domain Name System Security Extensions (DNSSEC) algorithm numbers in this registry may be used in CERT RRs. Zonesigning (DNSSEC) and transaction security mechanisms (SIG(0) and TSIG) make use of particular subsets of these algorithms. The algorithm used for key signing should be a recommended one and it should be strong. When enabling DNSSEC for a managed zone, or creating a managed zone with DNSSEC, the user can select the DNSSEC signing algorithms and the denial-of-existence type. Changing the DNSSEC settings is only effective for a managed zone if DNSSEC is not already enabled. If there is a need to change the settings for a managed zone where it has been enabled, turn DNSSEC off and then re-enable it with different settings.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud CLI** 1. If it is necessary to change the settings for a managed zone where it has been enabled, DNSSEC must be turned off and re-enabled with different settings. To turn off DNSSEC, run the following command: gcloud dns managed-zones update ZONE_NAME --dnssec-state off 2. To update key-signing for a reported managed DNS Zone, run the following command: gcloud dns managed-zones update ZONE_NAME --dnssec-state on --ksk-algorithm KSK_ALGORITHM --ksk-key-length KSK_KEY_LENGTH --zsk-algorithm ZSK_ALGORITHM --zsk-key-length ZSK_KEY_LENGTH --denial-of-existence DENIAL_OF_EXISTENCE Supported algorithm options and key lengths are as follows. Algorithm KSK Length ZSK Length --------- ---------- ---------- RSASHA1 1024,2048 1024,2048 RSASHA256 1024,2048 1024,2048 RSASHA512 1024,2048 1024,2048 ECDSAP256SHA256 256 256 ECDSAP384SHA384 384 384", + "AuditProcedure": "**From Google Cloud CLI** Ensure the property algorithm for keyType keySigning is not using `RSASHA1`. gcloud dns managed-zones describe ZONENAME --format=json(dnsName,dnssecConfig.state,dnssecConfig.defaultKeySpecs)", + "AdditionalInformation": "1. RSASHA1 key-signing support may be required for compatibility reasons. 2. Remediation CLI works well with gcloud-cli version 221.0.0 and later.", + "References": "https://cloud.google.com/dns/dnssec-advanced#advanced_signing_options", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "3.5", + "Description": "Ensure That RSASHA1 Is Not Used for the Zone-Signing Key in Cloud DNS DNSSEC", + "Checks": [ + "dns_rsasha1_in_use_to_zone_sign_in_dnssec" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "NOTE: Currently, the SHA1 algorithm has been removed from general use by Google, and, if being used, needs to be whitelisted on a project basis by Google and will also, therefore, require a Google Cloud support contract. DNSSEC algorithm numbers in this registry may be used in CERT RRs. Zone signing (DNSSEC) and transaction security mechanisms (SIG(0) and TSIG) make use of particular subsets of these algorithms. The algorithm used for key signing should be a recommended one and it should be strong.", + "RationaleStatement": "DNSSEC algorithm numbers in this registry may be used in CERT RRs. Zone signing (DNSSEC) and transaction security mechanisms (SIG(0) and TSIG) make use of particular subsets of these algorithms. The algorithm used for key signing should be a recommended one and it should be strong. When enabling DNSSEC for a managed zone, or creating a managed zone with DNSSEC, the DNSSEC signing algorithms and the denial-of-existence type can be selected. Changing the DNSSEC settings is only effective for a managed zone if DNSSEC is not already enabled. If the need exists to change the settings for a managed zone where it has been enabled, turn DNSSEC off and then re-enable it with different settings.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud CLI** 1. If the need exists to change the settings for a managed zone where it has been enabled, DNSSEC must be turned off and then re-enabled with different settings. To turn off DNSSEC, run following command: gcloud dns managed-zones update ZONE_NAME --dnssec-state off 2. To update zone-signing for a reported managed DNS Zone, run the following command: gcloud dns managed-zones update ZONE_NAME --dnssec-state on --ksk-algorithm KSK_ALGORITHM --ksk-key-length KSK_KEY_LENGTH --zsk-algorithm ZSK_ALGORITHM --zsk-key-length ZSK_KEY_LENGTH --denial-of-existence DENIAL_OF_EXISTENCE Supported algorithm options and key lengths are as follows. Algorithm KSK Length ZSK Length --------- ---------- ---------- RSASHA1 1024,2048 1024,2048 RSASHA256 1024,2048 1024,2048 RSASHA512 1024,2048 1024,2048 ECDSAP256SHA256 256 384 ECDSAP384SHA384 384 384", + "AuditProcedure": "**From Google Cloud CLI** Ensure the property algorithm for keyType zone signing is not using RSASHA1. gcloud dns managed-zones describe --format=json(dnsName,dnssecConfig.state,dnssecConfig.defaultKeySpecs) ", + "AdditionalInformation": "1. RSASHA1 zone-signing support may be required for compatibility reasons. 2. The remediation CLI works well with gcloud-cli version 221.0.0 and later.", + "References": "https://cloud.google.com/dns/dnssec-advanced#advanced_signing_options", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "3.6", + "Description": "Ensure That SSH Access Is Restricted From the Internet", + "Checks": [ + "compute_firewall_ssh_access_from_the_internet_allowed" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "GCP `Firewall Rules` are specific to a `VPC Network`. Each rule either `allows` or `denies` traffic when its conditions are met. Its conditions allow the user to specify the type of traffic, such as ports and protocols, and the source or destination of the traffic, including IP addresses, subnets, and instances. Firewall rules are defined at the VPC network level and are specific to the network in which they are defined. The rules themselves cannot be shared among networks. Firewall rules only support IPv4 traffic. When specifying a source for an ingress rule or a destination for an egress rule by address, only an `IPv4` address or `IPv4 block in CIDR` notation can be used. Generic `(0.0.0.0/0)` incoming traffic from the internet to VPC or VM instance using `SSH` on `Port 22` can be avoided.", + "RationaleStatement": "GCP `Firewall Rules` within a `VPC Network` apply to outgoing (egress) traffic from instances and incoming (ingress) traffic to instances in the network. Egress and ingress traffic flows are controlled even if the traffic stays within the network (for example, instance-to-instance communication). For an instance to have outgoing Internet access, the network must have a valid Internet gateway route or custom route whose destination IP is specified. This route simply defines the path to the Internet, to avoid the most general `(0.0.0.0/0)` destination `IP Range` specified from the Internet through `SSH` with the default `Port 22`. Generic access from the Internet to a specific IP Range needs to be restricted.", + "ImpactStatement": "All Secure Shell (SSH) connections from outside of the network to the concerned VPC(s) will be blocked. There could be a business need where SSH access is required from outside of the network to access resources associated with the VPC. In that case, specific source IP(s) should be mentioned in firewall rules to white-list access to SSH port for the concerned VPC(s).", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `VPC Network`. 2. Go to the `Firewall Rules`. 3. Click the `Firewall Rule` you want to modify. 4. Click `Edit`. 5. Modify `Source IP ranges` to specific `IP`. 6. Click `Save`. **From Google Cloud CLI** 1.Update the Firewall rule with the new `SOURCE_RANGE` from the below command: gcloud compute firewall-rules update FirewallName --allow=[PROTOCOL[:PORT[-PORT]],...] --source-ranges=[CIDR_RANGE,...]", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `VPC network`. 2. Go to the `Firewall Rules`. 3. Ensure that `Port` is not equal to `22` and `Action` is not set to `Allow`. 4. Ensure `IP Ranges` is not equal to `0.0.0.0/0` under `Source filters`. **From Google Cloud CLI** gcloud compute firewall-rules list --format=table'(name,direction,sourceRanges,allowed)' Ensure that there is no rule matching the below criteria: - `SOURCE_RANGES` is `0.0.0.0/0` - AND `DIRECTION` is `INGRESS` - AND IPProtocol is `tcp` or `ALL` - AND `PORTS` is set to `22` or `range containing 22` or `Null (not set)` Note: - When ALL TCP ports are allowed in a rule, PORT does not have any value set (`NULL`) - When ALL Protocols are allowed in a rule, PORT does not have any value set (`NULL`)", + "AdditionalInformation": "Currently, GCP VPC only supports IPV4; however, Google is already working on adding IPV6 support for VPC. In that case along with source IP range `0.0.0.0`, the rule should be checked for IPv6 equivalent `::/0` as well.", + "References": "https://cloud.google.com/vpc/docs/firewalls#blockedtraffic:https://cloud.google.com/blog/products/identity-security/cloud-iap-enables-context-aware-access-to-vms-via-ssh-and-rdp-without-bastion-hosts", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "3.7", + "Description": "Ensure That RDP Access Is Restricted From the Internet", + "Checks": [ + "compute_firewall_rdp_access_from_the_internet_allowed" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "GCP `Firewall Rules` are specific to a `VPC Network`. Each rule either `allows` or `denies` traffic when its conditions are met. Its conditions allow users to specify the type of traffic, such as ports and protocols, and the source or destination of the traffic, including IP addresses, subnets, and instances. Firewall rules are defined at the VPC network level and are specific to the network in which they are defined. The rules themselves cannot be shared among networks. Firewall rules only support IPv4 traffic. When specifying a source for an ingress rule or a destination for an egress rule by address, an `IPv4` address or `IPv4 block in CIDR` notation can be used. Generic `(0.0.0.0/0)` incoming traffic from the Internet to a VPC or VM instance using `RDP` on `Port 3389` can be avoided.", + "RationaleStatement": "GCP `Firewall Rules` within a `VPC Network`. These rules apply to outgoing (egress) traffic from instances and incoming (ingress) traffic to instances in the network. Egress and ingress traffic flows are controlled even if the traffic stays within the network (for example, instance-to-instance communication). For an instance to have outgoing Internet access, the network must have a valid Internet gateway route or custom route whose destination IP is specified. This route simply defines the path to the Internet, to avoid the most general `(0.0.0.0/0)` destination `IP Range` specified from the Internet through `RDP` with the default `Port 3389`. Generic access from the Internet to a specific IP Range should be restricted.", + "ImpactStatement": "All Remote Desktop Protocol (RDP) connections from outside of the network to the concerned VPC(s) will be blocked. There could be a business need where secure shell access is required from outside of the network to access resources associated with the VPC. In that case, specific source IP(s) should be mentioned in firewall rules to white-list access to RDP port for the concerned VPC(s).", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `VPC Network`. 2. Go to the `Firewall Rules`. 3. Click the `Firewall Rule` to be modified. 4. Click `Edit`. 5. Modify `Source IP ranges` to specific `IP`. 6. Click `Save`. **From Google Cloud CLI** 1.Update RDP Firewall rule with new `SOURCE_RANGE` from the below command: gcloud compute firewall-rules update FirewallName --allow=[PROTOCOL[:PORT[-PORT]],...] --source-ranges=[CIDR_RANGE,...]", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `VPC network`. 2. Go to the `Firewall Rules`. 3. Ensure `Port` is not equal to `3389` and `Action` is not `Allow`. 4. Ensure `IP Ranges` is not equal to `0.0.0.0/0` under `Source filters`. **From Google Cloud CLI** gcloud compute firewall-rules list --format=table'(name,direction,sourceRanges,allowed)' Ensure that there is no rule matching the below criteria: - `SOURCE_RANGES` is `0.0.0.0/0` - AND `DIRECTION` is `INGRESS` - AND IPProtocol is `TCP` or `ALL` - AND `PORTS` is set to `3389` or `range containing 3389` or `Null (not set)` Note: - When ALL TCP ports are allowed in a rule, PORT does not have any value set (`NULL`) - When ALL Protocols are allowed in a rule, PORT does not have any value set (`NULL`)", + "AdditionalInformation": "Currently, GCP VPC only supports IPV4; however, Google is already working on adding IPV6 support for VPC. In that case along with source IP range `0.0.0.0`, the rule should be checked for IPv6 equivalent `::/0` as well.", + "References": "https://cloud.google.com/vpc/docs/firewalls#blockedtraffic:https://cloud.google.com/blog/products/identity-security/cloud-iap-enables-context-aware-access-to-vms-via-ssh-and-rdp-without-bastion-hosts", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "3.8", + "Description": "Ensure VPC Service Controls Is Enabled for Supported Google Cloud Services", + "Checks": [], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that VPC Service Controls are configured to create security perimeters around sensitive Google Cloud resources, preventing unauthorized data exfiltration and limiting access to resources from approved networks and identities. VPC Service Controls provide an additional layer of defense-in-depth by allowing organizations to define trust boundaries around GCP services and control data movement across those boundaries.", + "RationaleStatement": "VPC Service Controls provide critical protection against data exfiltration attacks by creating security perimeters that restrict data access based on client identity, device state, and network origin. Without VPC Service Controls, an attacker who compromises credentials or exploits misconfigurations can freely move data between projects, organizations, or external systems. VPC Service Controls enforce defense-in-depth by adding network-level access controls that complement IAM policies. This control is essential for organizations handling regulated data or implementing zero-trust security architectures, and it also protects against supply chain attacks by restricting which services and APIs can interact with protected resources.", + "ImpactStatement": "Implementing VPC Service Controls requires careful planning as it restricts network access to protected resources. Services outside the perimeter will be denied access to resources inside the perimeter by default, which may impact application connectivity, cross-project data pipelines, third-party integrations, and administrative access from non-corporate networks. Organizations should use VPC Service Controls dry-run mode to test policies before enforcement. There is no direct performance impact on services within the perimeter.", + "AuditProcedure": "Note: The following audit instructions use Cloud Storage (GCS) as an example. Similar steps should be followed for other supported services (BigQuery, Cloud SQL, Bigtable, etc.).\n\n**From Google Cloud CLI**\n\n1. Check if the Access Context Manager API is enabled:\n`gcloud services list --enabled --filter=\"name:accesscontextmanager.googleapis.com\" --format=\"value(name)\"`\n2. List Access Context Manager policies and perimeters in the organization:\n`gcloud access-context-manager policies list --organization=`\n`gcloud access-context-manager perimeters list --policy=`\n3. Check if a project's resources are protected by a perimeter and verify perimeter configuration with `gcloud access-context-manager perimeters describe --policy=`.\n4. Check whether perimeters have access levels configured for conditional access.", + "RemediationProcedure": "Note: The following remediation instructions use Cloud Storage (GCS) as an example. Similar steps should be followed for other supported services.\n\n**From Google Cloud CLI**\n\n1. Create an Access Context Manager policy if none exists: `gcloud access-context-manager policies create --organization= --title=\"VPC Service Controls Policy\"`.\n2. Create a service perimeter to protect resources: `gcloud access-context-manager perimeters create --resources=projects/ --restricted-services=storage.googleapis.com --policy= --perimeter-type=regular`.\n3. Add additional projects and restricted services to the perimeter as needed.\n4. Test with dry-run mode before enforcement and monitor dry-run logs for blocked requests, then enforce the perimeter with `gcloud access-context-manager perimeters dry-run enforce`.\n5. Optionally create access levels for conditional access and add them to the perimeter.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, VPC Service Controls are not configured and no service perimeters protect Google Cloud resources." + } + ] + }, + { + "Id": "3.9", + "Description": "Ensure Private Service Connect is Used for Access to Google APIs", + "Checks": [], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that VPCs use Private Service Connect endpoints for access to Google APIs such as Cloud Storage, BigQuery, Pub/Sub, Artifact Registry, and other googleapis.com services, so that traffic from workloads to these APIs stays on the Google private network instead of traversing the public internet. Private Service Connect for Google APIs creates a dedicated endpoint in your VPC with firewall rule enforcement, providing more granular control than Private Google Access.", + "RationaleStatement": "Accessing Google APIs over the public internet exposes traffic to network-level threats, requires managing NAT gateways or external IPs, and makes it harder to enforce egress controls. Private Service Connect for Google APIs allows workloads to reach googleapis.com services over Google's private network using a dedicated endpoint in your VPC. Combined with VPC firewall rules, PSC ensures only authorized workloads can access Google APIs, preventing unauthorized API usage and enforcing least-privilege network access. This control eliminates reliance on internet connectivity for API access, enables granular network segmentation per API consumer, supports VPC Service Controls integration, and is essential for zero-trust architectures.", + "ImpactStatement": "Implementing Private Service Connect for Google APIs requires creating PSC endpoints in each VPC and configuring DNS to resolve googleapis.com domains to the private endpoint IP addresses. This introduces per-endpoint charges and operational overhead for DNS management. Migrating from public API access requires updating DNS configurations and may require changes to firewall rules. Workloads without proper firewall rules or DNS configuration will be unable to reach Google APIs after migration.", + "AuditProcedure": "**From Google Cloud CLI**\n\n1. Identify in-scope VPCs that host production or sensitive workloads accessing Google APIs: `gcloud compute networks list --format=\"table(name)\"`.\n2. For each in-scope VPC, check for a Private Service Connect endpoint for Google APIs: `gcloud compute addresses list --filter=\"purpose=PRIVATE_SERVICE_CONNECT\"`. At least one PSC endpoint should exist per VPC that needs Google API access.\n3. Verify the PSC endpoint targets Google's service attachment by listing global forwarding rules and looking for targets containing `all-apis` or `vpc-sc`.\n4. Check Cloud DNS for a private zone for googleapis.com with a wildcard A record pointing to the PSC endpoint IP.\n5. Verify firewall rules restrict access to the PSC endpoint using specific sourceRanges/sourceTags and do not allow 0.0.0.0/0.", + "RemediationProcedure": "**From Google Cloud CLI**\n\n1. Reserve an internal IP for the PSC endpoint: `gcloud compute addresses create psc-apis-endpoint --global --purpose=PRIVATE_SERVICE_CONNECT --addresses= --network=projects//global/networks/`.\n2. Create the PSC endpoint forwarding rule targeting Google's all-apis bundle: `gcloud compute forwarding-rules create pscapis --global --network= --address=psc-apis-endpoint --target-google-apis-bundle=all-apis`.\n3. Configure DNS by creating a private zone for googleapis.com and a wildcard A record pointing to the PSC endpoint IP.\n4. Configure VPC firewall rules to restrict PSC endpoint access to specific subnets only.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, workloads access Google APIs over public Google API endpoints rather than through Private Service Connect endpoints." + } + ] + }, + { + "Id": "3.10", + "Description": "Ensure that VPC Flow Logs is Enabled for Every Subnet in a VPC Network", + "Checks": [ + "compute_subnet_flow_logs_enabled" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Flow Logs is a feature that enables users to capture information about the IP traffic going to and from network interfaces in the organization's VPC Subnets. Once a flow log is created, the user can view and retrieve its data in Stackdriver Logging. It is recommended that Flow Logs be enabled for every business-critical VPC subnet.", + "RationaleStatement": "VPC networks and subnetworks not reserved for internal HTTP(S) load balancing provide logically isolated and secure network partitions where GCP resources can be launched. When Flow Logs are enabled for a subnet, VMs within that subnet start reporting on all Transmission Control Protocol (TCP) and User Datagram Protocol (UDP) flows. Each VM samples the TCP and UDP flows it sees, inbound and outbound, whether the flow is to or from another VM, a host in the on-premises datacenter, a Google service, or a host on the Internet. If two GCP VMs are communicating, and both are in subnets that have VPC Flow Logs enabled, both VMs report the flows. Flow Logs supports the following use cases: - Network monitoring - Understanding network usage and optimizing network traffic expenses - Network forensics - Real-time security analysis Flow Logs provide visibility into network traffic for each VM inside the subnet and can be used to detect anomalous traffic or provide insight during security workflows. The Flow Logs must be configured such that all network traffic is logged, the interval of logging is granular to provide detailed information on the connections, no logs are filtered, and metadata to facilitate investigations are included. **Note**: Subnets reserved for use by internal HTTP(S) load balancers do not support VPC flow logs.", + "ImpactStatement": "Standard pricing for Stackdriver Logging, BigQuery, or Cloud Pub/Sub applies. VPC Flow Logs generation will be charged starting in GA as described in reference: https://cloud.google.com/vpc/", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the VPC network GCP Console visiting `https://console.cloud.google.com/networking/networks/list` 2. Click the name of a subnet, The `Subnet details` page displays. 3. Click the `EDIT` button. 4. Set `Flow Logs` to `On`. 5. Expand the `Configure Logs` section. 6. Set `Aggregation Interval` to `5 SEC`. 7. Check the box beside `Include metadata`. 8. Set `Sample rate` to `100`. 9. Click Save. **Note**: It is not possible to configure a Log filter from the console. **From Google Cloud CLI** To enable VPC Flow Logs for a network subnet, run the following command: gcloud compute networks subnets update [SUBNET_NAME] --region [REGION] --enable-flow-logs --logging-aggregation-interval=interval-5-sec --logging-flow-sampling=1 --logging-metadata=include-all ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the VPC network GCP Console visiting `https://console.cloud.google.com/networking/networks/list` 2. From the list of network subnets, make sure for each subnet: - `Flow Logs` is set to `On` - `Aggregation Interval` is set to `5 sec` - `Include metadata` checkbox is checked - `Sample rate` is set to `100%` **Note**: It is not possible to determine if a Log filter has been defined from the console. **From Google Cloud CLI** gcloud compute networks subnets list --format json | jq -r '([Subnet,Purpose,Flow_Logs,Aggregation_Interval,Flow_Sampling,Metadata,Logs_Filtered] | (., map(length*-))), (.[] | [ .name, .purpose, (if has(enableFlowLogs) and .enableFlowLogs == true then Enabled else Disabled end), (if has(logConfig) then .logConfig.aggregationInterval else N/A end), (if has(logConfig) then .logConfig.flowSampling else N/A end), (if has(logConfig) then .logConfig.metadata else N/A end), (if has(logConfig) then (.logConfig | has(filterExpr)) else N/A end) ] ) | @tsv' | column -t The output of the above command will list: - each subnet - the subnet's purpose - a `Enabled` or `Disabled` value if `Flow Logs` are enabled - the value for `Aggregation Interval` or `N/A` if disabled, the value for `Flow Sampling` or `N/A` if disabled - the value for `Metadata` or `N/A` if disabled - 'true' or 'false' if a Logging Filter is configured or 'N/A' if disabled. If the subnet's purpose is `PRIVATE` then `Flow Logs` should be `Enabled`. If `Flow Logs` is enabled then: - `Aggregation_Interval` should be `INTERVAL_5_SEC` - `Flow_Sampling` should be 1 - `Metadata` should be `INCLUDE_ALL_METADATA` - `Logs_Filtered` should be `false`.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/vpc/docs/using-flow-logs#enabling_vpc_flow_logging:https://cloud.google.com/vpc/", + "DefaultValue": "By default, Flow Logs is set to Off when a new VPC network subnet is created.", + "SubSection": null + } + ] + }, + { + "Id": "3.11", + "Description": "Ensure No HTTPS or SSL Proxy Load Balancers Permit SSL Policies With Weak Cipher Suites", + "Checks": [], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Secure Sockets Layer (SSL) policies determine what port Transport Layer Security (TLS) features clients are permitted to use when connecting to load balancers. To prevent usage of insecure features, SSL policies should use (a) at least TLS 1.2 with the MODERN profile; or (b) the RESTRICTED profile, because it effectively requires clients to use TLS 1.2 regardless of the chosen minimum TLS version; or (3) a CUSTOM profile that does not support any of the following features: TLS_RSA_WITH_AES_128_GCM_SHA256 TLS_RSA_WITH_AES_256_GCM_SHA384 TLS_RSA_WITH_AES_128_CBC_SHA TLS_RSA_WITH_AES_256_CBC_SHA TLS_RSA_WITH_3DES_EDE_CBC_SHA ", + "RationaleStatement": "Load balancers are used to efficiently distribute traffic across multiple servers. Both SSL proxy and HTTPS load balancers are external load balancers, meaning they distribute traffic from the Internet to a GCP network. GCP customers can configure load balancer SSL policies with a minimum TLS version (1.0, 1.1, or 1.2) that clients can use to establish a connection, along with a profile (Compatible, Modern, Restricted, or Custom) that specifies permissible cipher suites. To comply with users using outdated protocols, GCP load balancers can be configured to permit insecure cipher suites. In fact, the GCP default SSL policy uses a minimum TLS version of 1.0 and a Compatible profile, which allows the widest range of insecure cipher suites. As a result, it is easy for customers to configure a load balancer without even knowing that they are permitting outdated cipher suites.", + "ImpactStatement": "Creating more secure SSL policies can prevent clients using older TLS versions from establishing a connection.", + "RemediationProcedure": "**From Google Cloud Console** If the TargetSSLProxy or TargetHttpsProxy does not have an SSL policy configured, create a new SSL policy. Otherwise, modify the existing insecure policy. 1. Navigate to the `SSL Policies` page by visiting: [https://console.cloud.google.com/net-security/sslpolicies](https://console.cloud.google.com/net-security/sslpolicies) 2. Click on the name of the insecure policy to go to its `SSL policy details` page. 3. Click `EDIT`. 4. Set `Minimum TLS version` to `TLS 1.2`. 5. Set `Profile` to `Modern` or `Restricted`. 6. Alternatively, if teh user selects the profile `Custom`, make sure that the following features are disabled: TLS_RSA_WITH_AES_128_GCM_SHA256 TLS_RSA_WITH_AES_256_GCM_SHA384 TLS_RSA_WITH_AES_128_CBC_SHA TLS_RSA_WITH_AES_256_CBC_SHA TLS_RSA_WITH_3DES_EDE_CBC_SHA **From Google Cloud CLI** 1. For each insecure SSL policy, update it to use secure cyphers: gcloud compute ssl-policies update NAME [--profile COMPATIBLE|MODERN|RESTRICTED|CUSTOM] --min-tls-version 1.2 [--custom-features FEATURES] 2. If the target proxy has a GCP default SSL policy, use the following command corresponding to the proxy type to update it. gcloud compute target-ssl-proxies update TARGET_SSL_PROXY_NAME --ssl-policy SSL_POLICY_NAME gcloud compute target-https-proxies update TARGET_HTTPS_POLICY_NAME --ssl-policy SSL_POLICY_NAME ", + "AuditProcedure": "**From Google Cloud Console** 1. See all load balancers by visiting [https://console.cloud.google.com/net-services/loadbalancing/loadBalancers/list](https://console.cloud.google.com/net-services/loadbalancing/loadBalancers/list). 2. For each load balancer for `SSL (Proxy)` or `HTTPS`, click on its name to go the `Load balancer details` page. 3. Ensure that each target proxy entry in the `Frontend` table has an `SSL Policy` configured. 4. Click on each SSL policy to go to its `SSL policy details` page. 5. Ensure that the SSL policy satisfies one of the following conditions: - has a `Min TLS` set to `TLS 1.2` and `Profile` set to `Modern` profile, or - has `Profile` set to `Restricted`. Note that a Restricted profile effectively requires clients to use TLS 1.2 regardless of the chosen minimum TLS version, or - has `Profile` set to `Custom` and the following features are all disabled: TLS_RSA_WITH_AES_128_GCM_SHA256 TLS_RSA_WITH_AES_256_GCM_SHA384 TLS_RSA_WITH_AES_128_CBC_SHA TLS_RSA_WITH_AES_256_CBC_SHA TLS_RSA_WITH_3DES_EDE_CBC_SHA **From Google Cloud CLI** 1. List all TargetHttpsProxies and TargetSslProxies. gcloud compute target-https-proxies list gcloud compute target-ssl-proxies list 2. For each target proxy, list its properties: gcloud compute target-https-proxies describe TARGET_HTTPS_PROXY_NAME gcloud compute target-ssl-proxies describe TARGET_SSL_PROXY_NAME 3. Ensure that the `sslPolicy` field is present and identifies the name of the SSL policy: sslPolicy: https://www.googleapis.com/compute/v1/projects/PROJECT_ID/global/sslPolicies/SSL_POLICY_NAME If the `sslPolicy` field is missing from the configuration, it means that the GCP default policy is used, which is insecure. 4. Describe the SSL policy: gcloud compute ssl-policies describe SSL_POLICY_NAME 5. Ensure that the policy satisfies one of the following conditions: - has `Profile` set to `Modern` and `minTlsVersion` set to `TLS_1_2`, or - has `Profile` set to `Restricted`, or - has `Profile` set to `Custom` and `enabledFeatures` does not contain any of the following values: TLS_RSA_WITH_AES_128_GCM_SHA256 TLS_RSA_WITH_AES_256_GCM_SHA384 TLS_RSA_WITH_AES_128_CBC_SHA TLS_RSA_WITH_AES_256_CBC_SHA TLS_RSA_WITH_3DES_EDE_CBC_SHA ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/load-balancing/docs/use-ssl-policies:https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-52r2.pdf", + "DefaultValue": "The GCP default SSL policy is the least secure setting: Min TLS 1.0 and Compatible profile", + "SubSection": null + } + ] + }, + { + "Id": "3.12", + "Description": "Use Identity Aware Proxy (IAP) to Ensure Only Traffic From Google IP Addresses are 'Allowed'", + "Checks": [], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "IAP authenticates the user requests to your apps via a Google single sign in. You can then manage these users with permissions to control access. It is recommended to use both IAP permissions and firewalls to restrict this access to your apps with sensitive information.", + "RationaleStatement": "IAP ensure that access to VMs is controlled by authenticating incoming requests. Access to your apps and the VMs should be restricted by firewall rules that allow only the proxy IAP IP addresses contained in the 35.235.240.0/20 subnet. Otherwise, unauthenticated requests can be made to your apps. To ensure that load balancing works correctly health checks should also be allowed.", + "ImpactStatement": "If firewall rules are not configured correctly, legitimate business services could be negatively impacted. It is recommended to make these changes during a time of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud Console [VPC network > Firewall rules](https://console.cloud.google.com/networking/firewalls/list?_ga=2.72166934.480049361.1580860862-1336643914.1580248695). 2. Select the checkbox next to the following rules: - default-allow-http - default-allow-https - default-allow-internal 3. Click `Delete`. 4. Click `Create firewall rule` and set the following values: - Name: allow-iap-traffic - Targets: All instances in the network - Source IP ranges (press Enter after you paste each value in the box, copy each full CIDR IP address): - IAP Proxy Addresses `35.235.240.0/20` - Google Health Check `130.211.0.0/22` - Google Health Check `35.191.0.0/16` - Protocols and ports: - Specified protocols and ports required for access and management of your app. For example most health check connection protocols would be covered by; - tcp:80 (Default HTTP Health Check port) - tcp:443 (Default HTTPS Health Check port) **Note: if you have custom ports used by your load balancers, you will need to list them here** 5. When you're finished updating values, click `Create`.", + "AuditProcedure": "**From Google Cloud Console** 1. For each of your apps that have IAP enabled go to the Cloud Console VPC network > Firewall rules. 2. Verify that the only rules correspond to the following values: - Targets: All instances in the network - Source IP ranges: - IAP Proxy Addresses `35.235.240.0/20` - Google Health Check `130.211.0.0/22` - Google Health Check `35.191.0.0/16` - Protocols and ports: - Specified protocols and ports required for access and management of your app. For example most health check connection protocols would be covered by; - tcp:80 (Default HTTP Health Check port) - tcp:443 (Default HTTPS Health Check port) **Note: if you have custom ports used by your load balancers, you will need to list them here**", + "AdditionalInformation": "", + "References": "https://cloud.google.com/iap/docs/concepts-overview:https://cloud.google.com/iap/docs/load-balancer-howto:https://cloud.google.com/load-balancing/docs/health-checks:https://cloud.google.com/blog/products/identity-security/cloud-iap-enables-context-aware-access-to-vms-via-ssh-and-rdp-without-bastion-hosts", + "DefaultValue": "By default all traffic is allowed.", + "SubSection": null + } + ] + }, + { + "Id": "4.1", + "Description": "Ensure That Instances Are Not Configured To Use the Default Service Account", + "Checks": [ + "compute_instance_default_service_account_in_use" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to configure your instance to not use the default Compute Engine service account because it has the Editor role on the project.", + "RationaleStatement": "When a default Compute Engine service account is created, it is automatically granted the Editor role (roles/editor) on your project which allows read and write access to most Google Cloud Services. This role includes a very large number of permissions. To defend against privilege escalations if your VM is compromised and prevent an attacker from gaining access to all of your project, you should either revoke the Editor role from the default Compute Engine service account or create a new service account and assign only the permissions needed by your instance. To mitigate this at scale, we strongly recommend that you disable the automatic role grant by adding a constraint to your organization policy. The default Compute Engine service account is named `[PROJECT_NUMBER]-compute@developer.gserviceaccount.com`.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on the instance name to go to its `VM instance details` page. 3. Click `STOP` and then click `EDIT`. 4. Under the section `API and identity management`, select a service account other than the default Compute Engine service account. You may first need to create a new service account. 5. Click `Save` and then click `START`. **From Google Cloud CLI** 1. Stop the instance: gcloud compute instances stop 2. Update the instance: gcloud compute instances set-service-account --service-account= 3. Restart the instance: gcloud compute instances start ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on each instance name to go to its `VM instance details` page. 3. Under the section `API and identity management`, ensure that the default Compute Engine service account is not used. This account is named `[PROJECT_NUMBER]-compute@developer.gserviceaccount.com`. **From Google Cloud CLI** 1. List the instances in your project and get details on each instance: gcloud compute instances list --format=json | jq -r '. | SA: (.[].serviceAccounts[].email) Name: (.[].name)' 2. Ensure that the service account section has an email that does not match the pattern `[PROJECT_NUMBER]-compute@developer.gserviceaccount.com`. **Exception:** VMs created by GKE should be excluded. These VMs have names that start with `gke-` and are labeled `goog-gke-node`.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/compute/docs/access/service-accounts:https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances:https://cloud.google.com/sdk/gcloud/reference/compute/instances/set-service-account", + "DefaultValue": "By default, Compute instances are configured to use the default Compute Engine service account.", + "SubSection": null + } + ] + }, + { + "Id": "4.2", + "Description": "Ensure That Instances Are Not Configured To Use the Default Service Account With Full Access to All Cloud APIs", + "Checks": [ + "compute_instance_default_service_account_in_use_with_full_api_access" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "To support principle of least privileges and prevent potential privilege escalation it is recommended that instances are not assigned to default service account `Compute Engine default service account` with Scope `Allow full access to all Cloud APIs`.", + "RationaleStatement": "Along with ability to optionally create, manage and use user managed custom service accounts, Google Compute Engine provides default service account `Compute Engine default service account` for an instances to access necessary cloud services. `Project Editor` role is assigned to `Compute Engine default service account` hence, This service account has almost all capabilities over all cloud services except billing. However, when `Compute Engine default service account` assigned to an instance it can operate in 3 scopes. 1. Allow default access: Allows only minimum access required to run an Instance (Least Privileges) 2. Allow full access to all Cloud APIs: Allow full access to all the cloud APIs/Services (Too much access) 3. Set access for each API: Allows Instance administrator to choose only those APIs that are needed to perform specific business functionality expected by instance When an instance is configured with `Compute Engine default service account` with Scope `Allow full access to all Cloud APIs`, based on IAM roles assigned to the user(s) accessing Instance, it may allow user to perform cloud operations/API calls that user is not supposed to perform leading to successful privilege escalation.", + "ImpactStatement": "In order to change service account or scope for an instance, it needs to be stopped.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on the impacted VM instance. 3. If the instance is not stopped, click the `Stop` button. Wait for the instance to be stopped. 4. Next, click the `Edit` button. 5. Scroll down to the `Service Account` section. 6. Select a different service account or ensure that `Allow full access to all Cloud APIs` is not selected. 7. Click the `Save` button to save your changes and then click `START`. **From Google Cloud CLI** 1. Stop the instance: gcloud compute instances stop 2. Update the instance: gcloud compute instances set-service-account --service-account= --scopes [SCOPE1, SCOPE2...] 3. Restart the instance: gcloud compute instances start ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on each instance name to go to its `VM instance details` page. 3. Under the `API and identity management`, ensure that `Cloud API access scopes` is not set to `Allow full access to all Cloud APIs`. **From Google Cloud CLI** 1. List the instances in your project and get details on each instance: gcloud compute instances list --format=json | jq -r '. | SA Scopes: (.[].serviceAccounts[].scopes) Name: (.[].name) Email: (.[].serviceAccounts[].email)' 2. Ensure that the service account section has an email that does not match the pattern `[PROJECT_NUMBER]-compute@developer.gserviceaccount.com`. **Exception:** VMs created by GKE should be excluded. These VMs have names that start with `gke-` and are labeled `goog-gke-node", + "AdditionalInformation": "- User IAM roles will override service account scope but configuring minimal scope ensures defense in depth - Non-default service accounts do not offer selection of access scopes like default service account. IAM roles with non-default service accounts should be used to control VM access.", + "References": "https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances:https://cloud.google.com/compute/docs/access/service-accounts", + "DefaultValue": "While creating an VM instance, default service account is used with scope `Allow default access`.", + "SubSection": null + } + ] + }, + { + "Id": "4.3", + "Description": "Ensure “Block Project-Wide SSH Keys” Is Enabled for VM Instances", + "Checks": [ + "compute_instance_block_project_wide_ssh_keys_disabled" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to use Instance specific SSH key(s) instead of using common/shared project-wide SSH key(s) to access Instances.", + "RationaleStatement": "Project-wide SSH keys are stored in Compute/Project-meta-data. Project wide SSH keys can be used to login into all the instances within project. Using project-wide SSH keys eases the SSH key management but if compromised, poses the security risk which can impact all the instances within project. It is recommended to use Instance specific SSH keys which can limit the attack surface if the SSH keys are compromised.", + "ImpactStatement": "Users already having Project-wide ssh key pairs and using third party SSH clients will lose access to the impacted Instances. For Project users using gcloud or GCP Console based SSH option, no manual key creation and distribution is required and will be handled by GCE (Google Compute Engine) itself. To access Instance using third party SSH clients Instance specific SSH key pairs need to be created and distributed to the required users.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). It will list all the instances in your project. 2. Click on the name of the Impacted instance 3. Click `Edit` in the toolbar 4. Under SSH Keys, go to the `Block project-wide SSH keys` checkbox 5. To block users with project-wide SSH keys from connecting to this instance, select `Block project-wide SSH keys` 6. Click `Save` at the bottom of the page 7. Repeat steps for every impacted Instance **From Google Cloud CLI** To block project-wide public SSH keys, set the metadata value to `TRUE`: gcloud compute instances add-metadata --metadata block-project-ssh-keys=TRUE ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). It will list all the instances in your project. 2. For every instance, click on the name of the instance. 3. Under `SSH Keys`, ensure `Block project-wide SSH keys` is selected. **From Google Cloud CLI** 1. List the instances in your project and get details on each instance: gcloud compute instances list --format=json 2. Ensure `key: block-project-ssh-keys` is set to `value: 'true'`.", + "AdditionalInformation": "If OS Login is enabled, SSH keys in instance metadata are ignored, and therefore blocking project-wide SSH keys is not necessary.", + "References": "https://cloud.google.com/compute/docs/instances/adding-removing-ssh-keys:https://cloud.google.com/sdk/gcloud/reference/topic/formats", + "DefaultValue": "By Default `Block Project-wide SSH keys` is not enabled.", + "SubSection": null + } + ] + }, + { + "Id": "4.4", + "Description": "Ensure Oslogin Is Enabled for a Project", + "Checks": [ + "compute_project_os_login_enabled" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enabling OS login binds SSH certificates to IAM users and facilitates effective SSH certificate management.", + "RationaleStatement": "Enabling osLogin ensures that SSH keys used to connect to instances are mapped with IAM users. Revoking access to IAM user will revoke all the SSH keys associated with that particular user. It facilitates centralized and automated SSH key pair management which is useful in handling cases like response to compromised SSH key pairs and/or revocation of external/third-party/Vendor users.", + "ImpactStatement": "Enabling OS Login on project disables metadata-based SSH key configurations on all instances from a project. Disabling OS Login restores SSH keys that you have configured in project or instance meta-data.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the VM compute metadata page by visiting: [https://console.cloud.google.com/compute/metadata](https://console.cloud.google.com/compute/metadata). 2. Click `Edit`. 3. Add a metadata entry where the key is `enable-oslogin` and the value is `TRUE`. 4. Click `Save` to apply the changes. 5. For every instance that overrides the project setting, go to the `VM Instances` page at [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 6. Click the name of the instance on which you want to remove the metadata value. 7. At the top of the instance details page, click `Edit` to edit the instance settings. 8. Under `Custom metadata`, remove any entry with key `enable-oslogin` and the value is `FALSE` 9. At the bottom of the instance details page, click `Save` to apply your changes to the instance. **From Google Cloud CLI** 1. Configure oslogin on the project: gcloud compute project-info add-metadata --metadata enable-oslogin=TRUE 2. Remove instance metadata that overrides the project setting. gcloud compute instances remove-metadata --keys=enable-oslogin Optionally, you can enable two factor authentication for OS login. For more information, see: [https://cloud.google.com/compute/docs/oslogin/setup-two-factor-authentication](https://cloud.google.com/compute/docs/oslogin/setup-two-factor-authentication).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the VM compute metadata page by visiting [https://console.cloud.google.com/compute/metadata](https://console.cloud.google.com/compute/metadata). 2. Ensure that key `enable-oslogin` is present with value set to `TRUE`. 3. Because instances can override project settings, ensure that no instance has custom metadata with key `enable-oslogin` and value `FALSE`. **From Google Cloud CLI** 1. List the instances in your project and get details on each instance: gcloud compute instances list --format=json 2. Verify that the section `commonInstanceMetadata` has a key `enable-oslogin` set to value `TRUE`. **Exception:** VMs created by GKE should be excluded. These VMs have names that start with `gke-` and are labeled `goog-gke-node`", + "AdditionalInformation": "1. In order to use osLogin, instance using Custom Images must have the latest version of the Linux Guest Environment installed. The following image families do not yet support OS Login: Project cos-cloud (Container-Optimized OS) image family cos-stable. All project coreos-cloud (CoreOS) image families Project suse-cloud (SLES) image family sles-11 All Windows Server and SQL Server image families 2. Project enable-oslogin can be over-ridden by setting enable-oslogin parameter to an instance metadata individually.", + "References": "https://cloud.google.com/compute/docs/instances/managing-instance-access:https://cloud.google.com/compute/docs/instances/managing-instance-access#enable_oslogin:https://cloud.google.com/sdk/gcloud/reference/compute/instances/remove-metadata:https://cloud.google.com/compute/docs/oslogin/setup-two-factor-authentication", + "DefaultValue": "By default, parameter `enable-oslogin` is not set, which is equivalent to setting it to `FALSE`.", + "SubSection": null + } + ] + }, + { + "Id": "4.5", + "Description": "Ensure ‘Enable Connecting to Serial Ports’ Is Not Enabled for VM Instance", + "Checks": [ + "compute_instance_serial_ports_in_use" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Interacting with a serial port is often referred to as the serial console, which is similar to using a terminal window, in that input and output is entirely in text mode and there is no graphical interface or mouse support. If you enable the interactive serial console on an instance, clients can attempt to connect to that instance from any IP address. Therefore interactive serial console support should be disabled.", + "RationaleStatement": "A virtual machine instance has four virtual serial ports. Interacting with a serial port is similar to using a terminal window, in that input and output is entirely in text mode and there is no graphical interface or mouse support. The instance's operating system, BIOS, and other system-level entities often write output to the serial ports, and can accept input such as commands or answers to prompts. Typically, these system-level entities use the first serial port (port 1) and serial port 1 is often referred to as the serial console. The interactive serial console does not support IP-based access restrictions such as IP whitelists. If you enable the interactive serial console on an instance, clients can attempt to connect to that instance from any IP address. This allows anybody to connect to that instance if they know the correct SSH key, username, project ID, zone, and instance name. Therefore interactive serial console support should be disabled.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud Console** 1. Login to Google Cloud console 2. Go to Computer Engine 3. Go to VM instances 4. Click on the Specific VM 5. Click `EDIT` 6. Unselect `Enable connecting to serial ports` below `Remote access` block. 7. Click `Save` **From Google Cloud CLI** Use the below command to disable gcloud compute instances add-metadata --zone= --metadata=serial-port-enable=false or gcloud compute instances add-metadata --zone= --metadata=serial-port-enable=0 **Prevention:** You can prevent VMs from having serial port access enable by `Disable VM serial port access` organization policy: [https://console.cloud.google.com/iam-admin/orgpolicies/compute-disableSerialPortAccess](https://console.cloud.google.com/iam-admin/orgpolicies/compute-disableSerialPortAccess).", + "AuditProcedure": "**From Google Cloud Console** 1. Login to Google Cloud console 2. Go to Compute Engine 3. Go to VM instances 4. Click on the Specific VM 5. Ensure the statement `Connecting to serial serial ports is disabled` is displayed at the top of the details tab, just below the `Connect to serial console` drop-down.. **From Google Cloud CLI** Ensure the below command's output shows `null`: gcloud compute instances describe --zone= --format=json(metadata.items[].key,metadata.items[].value) or `key` and `value` properties from below command's json response are equal to `serial-port-enable` and `0` or `false` respectively. { metadata: { items: [ { key: serial-port-enable, value: 0 } ] } } ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/compute/docs/instances/interacting-with-serial-console", + "DefaultValue": "By default, connecting to serial ports is not enabled.", + "SubSection": null + } + ] + }, + { + "Id": "4.6", + "Description": "Ensure That IP Forwarding Is Not Enabled on Instances", + "Checks": [ + "compute_instance_ip_forwarding_is_enabled" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Compute Engine instance cannot forward a packet unless the source IP address of the packet matches the IP address of the instance. Similarly, GCP won't deliver a packet whose destination IP address is different than the IP address of the instance receiving the packet. However, both capabilities are required if you want to use instances to help route packets. Forwarding of data packets should be disabled to prevent data loss or information disclosure.", + "RationaleStatement": "Compute Engine instance cannot forward a packet unless the source IP address of the packet matches the IP address of the instance. Similarly, GCP won't deliver a packet whose destination IP address is different than the IP address of the instance receiving the packet. However, both capabilities are required if you want to use instances to help route packets. To enable this source and destination IP check, disable the `canIpForward` field, which allows an instance to send and receive packets with non-matching destination or source IPs.", + "ImpactStatement": "", + "RemediationProcedure": "You only edit the `canIpForward` setting at instance creation or using CLI. **From Google Cloud CLI** 1. Use the instances export command to export the existing instance properties: gcloud compute instances export --project --zone --destination= **Note**Replace the following: INSTANCE_NAME the name for the instance that you want to export. PROJECT_ID: the project ID for this request. ZONE: the zone for this instance. FILE_PATH: the output path where you want to save the instance configuration file on your local workstation. 2. Use a text editor to modify this file Replace `canIpForward: true` with `canIpForward: false` 3. Run this command to import the file you just modified gcloud compute instances update-from-file INSTANCE_NAME --project PROJECT_ID --zone ZONE --source=FILE_PATH --most-disruptive-allowed-action=REFRESH If the update request is valid and the required resources are available, the instance update process begins. You can monitor the status of this operation by viewing the audit logs. This update requires only a REFRESH not a full restart.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VM Instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. For every instance, click on its name to go to the `VM instance details` page. 3. Under the `Network interfaces` section, ensure that `IP forwarding` is set to `Off` for every network interface. **From Google Cloud CLI** 1. List all instances: gcloud compute instances list --format='table(name,canIpForward)' 2. Ensure that `CAN_IP_FORWARD` column in the output of above command does not contain `True` for any VM instance. **Exception:** Instances created by GKE should be excluded because they need to have IP forwarding enabled and cannot be changed. Instances created by GKE have names that start with gke-.", + "AdditionalInformation": "You can only set the `canIpForward` field at instance creation time or using CLI.", + "References": "https://cloud.google.com/vpc/docs/using-routes#canipforward:https://cloud.google.com/compute/docs/instances/update-instance-properties", + "DefaultValue": "By default, instances are not configured to allow IP forwarding.", + "SubSection": null + } + ] + }, + { + "Id": "4.7", + "Description": "Ensure VM Disks for Critical VMs Are Encrypted With Customer-Supplied Encryption Keys (CSEK)", + "Checks": [ + "compute_instance_encryption_with_csek_enabled" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Customer-Supplied Encryption Keys (CSEK) are a feature in Google Cloud Storage and Google Compute Engine. If you supply your own encryption keys, Google uses your key to protect the Google-generated keys used to encrypt and decrypt your data. By default, Google Compute Engine encrypts all data at rest. Compute Engine handles and manages this encryption for you without any additional actions on your part. However, if you wanted to control and manage this encryption yourself, you can provide your own encryption keys.", + "RationaleStatement": "By default, Google Compute Engine encrypts all data at rest. Compute Engine handles and manages this encryption for you without any additional actions on your part. However, if you wanted to control and manage this encryption yourself, you can provide your own encryption keys. If you provide your own encryption keys, Compute Engine uses your key to protect the Google-generated keys used to encrypt and decrypt your data. Only users who can provide the correct key can use resources protected by a customer-supplied encryption key. Google does not store your keys on its servers and cannot access your protected data unless you provide the key. This also means that if you forget or lose your key, there is no way for Google to recover the key or to recover any data encrypted with the lost key. At least business critical VMs should have VM disks encrypted with CSEK.", + "ImpactStatement": "If you lose your encryption key, you will not be able to recover the data.", + "RemediationProcedure": "Currently there is no way to update the encryption of an existing disk. Therefore you should create a new disk with `Encryption` set to `Customer supplied`. **From Google Cloud Console** 1. Go to Compute Engine `Disks` by visiting: [https://console.cloud.google.com/compute/disks](https://console.cloud.google.com/compute/disks). 2. Click `CREATE DISK`. 3. Set `Encryption type` to `Customer supplied`, 4. Provide the `Key` in the box. 5. Select `Wrapped key`. 6. Click `Create`. **From Google Cloud CLI** In the gcloud compute tool, encrypt a disk using the --csek-key-file flag during instance creation. If you are using an RSA-wrapped key, use the gcloud beta component: gcloud compute instances create --csek-key-file To encrypt a standalone persistent disk: gcloud compute disks create --csek-key-file ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to Compute Engine `Disks` by visiting: [https://console.cloud.google.com/compute/disks](https://console.cloud.google.com/compute/disks). 2. Click on the disk for your critical VMs to see its configuration details. 4. Ensure that `Encryption type` is set to `Customer supplied`. **From Google Cloud CLI** Ensure `diskEncryptionKey` property in the below command's response is not null, and contains key `sha256` with corresponding value gcloud compute disks describe --zone --format=json(diskEncryptionKey,name) ", + "AdditionalInformation": "`Note 1:` When you delete a persistent disk, Google discards the cipher keys, rendering the data irretrievable. This process is irreversible. `Note 2:` It is up to you to generate and manage your key. You must provide a key that is a 256-bit string encoded in RFC 4648 standard base64 to Compute Engine. `Note 3:` An example key file looks like this. [ { uri: https://www.googleapis.com/compute/v1/projects/myproject/zones/us-central1-a/disks/example-disk, key: acXTX3rxrKAFTF0tYVLvydU1riRZTvUNC4g5I11NY-c=, key-type: raw }, { uri: https://www.googleapis.com/compute/v1/projects/myproject/global/snapshots/my-private-snapshot, key: ieCx/NcW06PcT7Ep1X6LUTc/hLvUDYyzSZPPVCVPTVEohpeHASqC8uw5TzyO9U+Fka9JFHz0mBibXUInrC/jEk014kCK/NPjYgEMOyssZ4ZINPKxlUh2zn1bV+MCaTICrdmuSBTWlUUiFoDD6PYznLwh8ZNdaheCeZ8ewEXgFQ8V+sDroLaN3Xs3MDTXQEMMoNUXMCZEIpg9Vtp9x2oeQ5lAbtt7bYAAHf5l+gJWw3sUfs0/Glw5fpdjT8Uggrr+RMZezGrltJEF293rvTIjWOEB3z5OHyHwQkvdrPDFcTqsLfh+8Hr8g+mf+7zVPEC8nEbqpdl3GPv3A7AwpFp7MA== key-type: rsa-encrypted } ]", + "References": "https://cloud.google.com/compute/docs/disks/customer-supplied-encryption#encrypt_a_new_persistent_disk_with_your_own_keys:https://cloud.google.com/compute/docs/reference/rest/v1/disks/get:https://cloud.google.com/compute/docs/disks/customer-supplied-encryption#key_file", + "DefaultValue": "By default, VM disks are encrypted with Google-managed keys. They are not encrypted with Customer-Supplied Encryption Keys.", + "SubSection": null + } + ] + }, + { + "Id": "4.8", + "Description": "Ensure Compute Instances Are Launched With Shielded VM Enabled", + "Checks": [ + "compute_instance_shielded_vm_enabled" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "To defend against advanced threats and ensure that the boot loader and firmware on your VMs are signed and untampered, it is recommended that Compute instances are launched with Shielded VM enabled.", + "RationaleStatement": "Shielded VMs are virtual machines (VMs) on Google Cloud Platform hardened by a set of security controls that help defend against rootkits and bootkits. Shielded VM offers verifiable integrity of your Compute Engine VM instances, so you can be confident your instances haven't been compromised by boot- or kernel-level malware or rootkits. Shielded VM's verifiable integrity is achieved through the use of Secure Boot, virtual trusted platform module (vTPM)-enabled Measured Boot, and integrity monitoring. Shielded VM instances run firmware which is signed and verified using Google's Certificate Authority, ensuring that the instance's firmware is unmodified and establishing the root of trust for Secure Boot. Integrity monitoring helps you understand and make decisions about the state of your VM instances and the Shielded VM vTPM enables Measured Boot by performing the measurements needed to create a known good boot baseline, called the integrity policy baseline. The integrity policy baseline is used for comparison with measurements from subsequent VM boots to determine if anything has changed. Secure Boot helps ensure that the system only runs authentic software by verifying the digital signature of all boot components, and halting the boot process if signature verification fails.", + "ImpactStatement": "", + "RemediationProcedure": "To be able turn on `Shielded VM` on an instance, your instance must use an image with Shielded VM support. **From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on the instance name to see its `VM instance details` page. 3. Click `STOP` to stop the instance. 4. When the instance has stopped, click `EDIT`. 5. In the Shielded VM section, select `Turn on vTPM` and `Turn on Integrity Monitoring`. 6. Optionally, if you do not use any custom or unsigned drivers on the instance, also select `Turn on Secure Boot`. 7. Click the `Save` button to modify the instance and then click `START` to restart it. **From Google Cloud CLI** You can only enable Shielded VM options on instances that have Shielded VM support. For a list of Shielded VM public images, run the gcloud compute images list command with the following flags: gcloud compute images list --project gce-uefi-images --no-standard-images 1. Stop the instance: gcloud compute instances stop 2. Update the instance: gcloud compute instances update --shielded-vtpm --shielded-vm-integrity-monitoring 3. Optionally, if you do not use any custom or unsigned drivers on the instance, also turn on secure boot. gcloud compute instances update --shielded-vm-secure-boot 4. Restart the instance: gcloud compute instances start **Prevention:** You can ensure that all new VMs will be created with Shielded VM enabled by setting up an Organization Policy to for `Shielded VM` at [https://console.cloud.google.com/iam-admin/orgpolicies/compute-requireShieldedVm](https://console.cloud.google.com/iam-admin/orgpolicies/compute-requireShieldedVm). Learn more at: [https://cloud.google.com/security/shielded-cloud/shielded-vm#organization-policy-constraint](https://cloud.google.com/security/shielded-cloud/shielded-vm#organization-policy-constraint).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on the instance name to see its `VM instance details` page. 3. Under the section `Shielded VM`, ensure that `vTPM` and `Integrity Monitoring` are `on`. **From Google Cloud CLI** 1. For each instance in your project, get its metadata: gcloud compute instances list --format=json | jq -r '. | vTPM: (.[].shieldedInstanceConfig.enableVtpm) IntegrityMonitoring: (.[].shieldedInstanceConfig.enableIntegrityMonitoring) Name: (.[].name)' 2. Ensure that there is a `shieldedInstanceConfig` configuration and that configuration has the `enableIntegrityMonitoring` and `enableVtpm` set to `true`. If the VM is not a Shield VM image, you will not see a shieldedInstanceConfig` in the output.", + "AdditionalInformation": "If you do use custom or unsigned drivers on the instance, enabling Secure Boot will cause the machine to no longer boot. Turn on Secure Boot only on instances that have been verified to not have any custom drivers installed.", + "References": "https://cloud.google.com/compute/docs/instances/modifying-shielded-vm:https://cloud.google.com/shielded-vm:https://cloud.google.com/security/shielded-cloud/shielded-vm#organization-policy-constraint", + "DefaultValue": "By default, Compute Instances do not have Shielded VM enabled.", + "SubSection": null + } + ] + }, + { + "Id": "4.9", + "Description": "Ensure That Compute Instances Do Not Have Public IP Addresses", + "Checks": [ + "compute_instance_public_ip" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Compute instances should not be configured to have external IP addresses.", + "RationaleStatement": "To reduce your attack surface, Compute instances should not have public IP addresses. Instead, instances should be configured behind load balancers, to minimize the instance's exposure to the internet.", + "ImpactStatement": "Removing the external IP address from your Compute instance may cause some applications to stop working.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on the instance name to go the the `Instance detail page`. 3. Click `Edit`. 4. For each Network interface, ensure that `External IP` is set to `None`. 5. Click `Done` and then click `Save`. **From Google Cloud CLI** 1. Describe the instance properties: gcloud compute instances describe --zone= 2. Identify the access config name that contains the external IP address. This access config appears in the following format: networkInterfaces: - accessConfigs: - kind: compute#accessConfig name: External NAT natIP: 130.211.181.55 type: ONE_TO_ONE_NAT 3. Delete the access config. gcloud compute instances delete-access-config --zone= --access-config-name In the above example, the `ACCESS_CONFIG_NAME` is `External NAT`. The name of your access config might be different. **Prevention:** You can configure the `Define allowed external IPs for VM instances` Organization Policy to prevent VMs from being configured with public IP addresses. Learn more at: [https://console.cloud.google.com/orgpolicies/compute-vmExternalIpAccess](https://console.cloud.google.com/orgpolicies/compute-vmExternalIpAccess)", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. For every VM, ensure that there is no `External IP` configured. **From Google Cloud CLI** gcloud compute instances list --format=json 1. The output should not contain an `accessConfigs` section under `networkInterfaces`. Note that the `natIP` value is present only for instances that are running or for instances that are stopped but have a static IP address. For instances that are stopped and are configured to have an ephemeral public IP address, the `natIP` field will not be present. Example output: networkInterfaces: - accessConfigs: - kind: compute#accessConfig name: External NAT networkTier: STANDARD type: ONE_TO_ONE_NAT **Exception:** Instances created by GKE should be excluded because some of them have external IP addresses and cannot be changed by editing the instance settings. Instances created by GKE should be excluded. These instances have names that start with gke- and are labeled goog-gke-node.", + "AdditionalInformation": "You can connect to Linux VMs that do not have public IP addresses by using Identity-Aware Proxy for TCP forwarding. Learn more at [https://cloud.google.com/compute/docs/instances/connecting-advanced#sshbetweeninstances](https://cloud.google.com/compute/docs/instances/connecting-advanced#sshbetweeninstances) For Windows VMs, see [https://cloud.google.com/compute/docs/instances/connecting-to-instance](https://cloud.google.com/compute/docs/instances/connecting-to-instance).", + "References": "https://cloud.google.com/load-balancing/docs/backend-service#backends_and_external_ip_addresses:https://cloud.google.com/compute/docs/instances/connecting-advanced#sshbetweeninstances:https://cloud.google.com/compute/docs/instances/connecting-to-instance:https://cloud.google.com/compute/docs/ip-addresses/reserve-static-external-ip-address#unassign_ip:https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints", + "DefaultValue": "By default, Compute instances have a public IP address.", + "SubSection": null + } + ] + }, + { + "Id": "4.10", + "Description": "Ensure That App Engine Applications Enforce HTTPS Connections", + "Checks": [], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "In order to maintain the highest level of security all connections to an application should be secure by default.", + "RationaleStatement": "Insecure HTTP connections maybe subject to eavesdropping which can expose sensitive data.", + "ImpactStatement": "All connections to appengine will automatically be redirected to the HTTPS endpoint ensuring that all connections are secured by TLS.", + "RemediationProcedure": "Add a line to the app.yaml file controlling the application which enforces secure connections. For example handlers: - url: /.* **secure: always** redirect_http_response_code: 301 script: auto [https://cloud.google.com/appengine/docs/standard/python3/config/appref]", + "AuditProcedure": "Verify that the app.yaml file controlling the application contains a line which enforces secure connections. For example handlers: - url: /.* secure: always redirect_http_response_code: 301 script: auto [https://cloud.google.com/appengine/docs/standard/python3/config/appref](https://cloud.google.com/appengine/docs/standard/python3/config/appref)", + "AdditionalInformation": "", + "References": "https://cloud.google.com/appengine/docs/standard/python3/config/appref:https://cloud.google.com/appengine/docs/flexible/nodejs/configuring-your-app-with-app-yaml", + "DefaultValue": "By default both HTTP and HTTP are supported", + "SubSection": null + } + ] + }, + { + "Id": "4.11", + "Description": "Ensure That Compute Instances Have Confidential Computing Enabled", + "Checks": [ + "compute_instance_confidential_computing_enabled" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Google Cloud encrypts data at-rest and in-transit, but customer data must be decrypted for processing. Confidential Computing is a breakthrough technology that encrypts data in-use while it is being processed. Confidential Computing environments keep data encrypted in memory and elsewhere outside the central processing unit (CPU). Confidential VMs leverage hardware-based memory encryption technologies, such as AMD Secure Encrypted Virtualization (SEV), AMD SEV-SNP, and Intel Trust Domain Extensions (TDX), depending on the chosen machine type and CPU platform. Customer data will stay encrypted while it is used, indexed, queried, or trained on. Encryption keys are generated by and reside solely in dedicated hardware and are not exportable, enhancing isolation and security. Built-in hardware optimizations ensure Confidential Computing workloads experience minimal to no significant performance penalties.", + "RationaleStatement": "Confidential Computing enables customers' sensitive code and other data encrypted in memory during processing. Google does not have access to the encryption keys. Confidential VM can help alleviate concerns about risk related to either dependency on Google infrastructure or Google insiders' access to customer data in the clear.", + "ImpactStatement": "- Confidential Computing for Compute instances does not support live migration. Unlike regular Compute instances, Confidential VMs experience disruptions during maintenance events like a software or hardware update. - Additional charges may be incurred when enabling this security feature. See [https://cloud.google.com/compute/confidential-vm/pricing](https://cloud.google.com/compute/confidential-vm/pricing) for more info.", + "RemediationProcedure": "Confidential Computing can only be enabled when an instance is created. You must delete the current instance and create a new one. **From Google Cloud Console** 1. Go to the VM instances page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click `CREATE INSTANCE`. 3. Fill out the desired configuration for your instance. 4. Under the `Confidential VM service` section, check the option `Enable the Confidential Computing service on this VM instance`. 5. Click `Create`. **From Google Cloud CLI** Create a new instance with Confidential Compute enabled. gcloud compute instances create --zone --confidential-compute --maintenance-policy=TERMINATE ", + "AuditProcedure": "Note: Confidential Computing is currently only supported on limited VM configurations. To learn more about VM configurations supported by Confidential Computing, visit [https://cloud.google.com/confidential-computing/confidential-vm/docs/supported-configurations](https://cloud.google.com/confidential-computing/confidential-vm/docs/supported-configurations) **From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on the instance name to see its VM instance details page. 3. Ensure that `Confidential VM service` is `Enabled`. **From Google Cloud CLI** 1. List the instances in your project and get details on each instance: gcloud compute instances list --format=json 2. Ensure that `enableConfidentialCompute` is set to `true` for all instances with machine type starting with n2d-. confidentialInstanceConfig: enableConfidentialCompute: true ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/compute/confidential-vm/docs/creating-cvm-instance:https://cloud.google.com/compute/confidential-vm/docs/about-cvm:https://cloud.google.com/confidential-computing:https://cloud.google.com/blog/products/identity-security/introducing-google-cloud-confidential-computing-with-confidential-vms", + "DefaultValue": "By default, Confidential Computing is disabled for Compute instances.", + "SubSection": null + } + ] + }, + { + "Id": "4.12", + "Description": "Ensure the Latest Operating System Updates Are Installed On Your Virtual Machines in All Projects", + "Checks": [], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Google Cloud Virtual Machines have the ability via an OS Config agent API to periodically (about every 10 minutes) report OS inventory data. A patch compliance API periodically reads this data, and cross references metadata to determine if the latest updates are installed. This is not the only Patch Management solution available to your organization and you should weigh your needs before committing to using this method.", + "RationaleStatement": "Keeping virtual machine operating systems up to date is a security best practice. Using this service will simplify this process.", + "ImpactStatement": "Most Operating Systems require a restart or changing critical resources to apply the updates. Using the Google Cloud VM manager for its OS Patch management will incur additional costs for each VM managed by it. Please view the VM manager pricing reference for further information.", + "RemediationProcedure": "**From Google Cloud Console** **Enabling OS Patch Management on a Project by Project Basis** **Install OS Config API for the Project** 1. Navigate into a project. In the expanded portal menu located at the top left of the screen hover over APIs & Services. Then in the menu right of that select API Libraries 2. Search for VM Manager (OS Config API) or scroll down in the left hand column and select the filter labeled Compute where it is the last listed. Open this API. 3. Click the blue 'Enable' button. **Add MetaData Tags for OSConfig Parsing** 1. From the main Google Cloud console, open the portal menu in the top left. Mouse over Computer Engine to expand the menu next to it. 2. Under the Settings heading, select Metadata. 3. In this view there will be a list of the project wide metadata tags for VMs. Click edit and 'add item' in the key column type 'enable-osconfig' and in the value column set it to 'true'. From Command Line 1. For project wide tagging, run the following command gcloud compute project-info add-metadata --project --metadata=enable-osconfig=TRUE Please see the reference /compute/docs/troubleshooting/vm-manager/verify-setup#metadata-enabled at the bottom for more options like instance specific tagging. Note: Adding a new tag via commandline may overwrite existing tags. You will need to do this at a time of low usage for the least impact. **Install and Start the Local OSConfig for Data Parsing** There is no way to centrally manage or start the Local OSConfig agent. Please view the reference of manage-os#agent-install to view specific operating system commands. **Setup a project wide Service Account** Please view Recommendation 4.1 to view how to setup a service account. Rerun the audit procedure to test if it has taken effect. **Enable NAT or Configure Private Google Access to allow Access to Public Update Hosting** For the sake of brevity, please see the attached resources to enable NAT or Private Google Access. Rerun the audit procedure to test if it has taken effect. From Command Line: **Install OS Config API for the Project** 1. In each project you wish to audit run gcloud services enable osconfig.googleapis.com **Install and Start the Local OSConfig for Data Parsing** Please view the reference of manage-os#agent-install to view specific operating system commands. **Setup a project wide Service Account** Please view Recommendation 4.1 to view how to setup a service account. Rerun the audit procedure to test if it has taken effect. **Enable NAT or Configure Private Google Access to allow Access to Public Update Hosting** For the sake of brevity, please see the attached resources to enable NAT or Private Google Access. Rerun the audit procedure to test if it has taken effect. Determine if Instances can connect to public update hosting Linux Debian Based Operating Systems sudo apt update The output should have a numbered list of lines with Hit: URL of updates. Redhat Based Operating Systems yum check-update The output should show a list of packages that have updates available. Windows ping http://windowsupdate.microsoft.com/ The ping should successfully be delivered and received.", + "AuditProcedure": "**From Google Cloud Console** **Determine if OS Config API is Enabled for the Project** 1. Navigate into a project. In the expanded navigation menu located at the top left of the screen hover over `APIs & Services`. Then in the menu right of that select `API Libraries` 2. Search for VM Manager (OS Config API) or scroll down in the left hand column and select the filter labeled Compute where it is the last listed. Open this API. 3. Verify the blue button at the top is enabled. **Determine if VM Instances have correct metadata tags for OSConfig parsing** 1. From the main Google Cloud console, open the hamburger menu in the top left. Mouse over Computer Engine to expand the menu next to it. 1. Under the Settings heading, select Metadata. 1. In this view there will be a list of the project wide metadata tags for VMs. Determine if the tag enable-osconfig is set to true. **Determine if the Operating System of VM Instances have the local OS-Config Agent running** There is no way to determine this from the Google Cloud console. The only way is to run operating specific commands locally inside the operating system via remote connection. For the sake of brevity of this recommendation please view the docs/troubleshooting/vm-manager/verify-setup reference at the bottom of the page. If you initialized your VM instance with a Google Supplied OS Image with a build date of later than v20200114 it will have the service installed. You should still determine its status for proper operation. **Verify the service account you have setup for the project in Recommendation 4.1 is running** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on each instance name to go to its `VM instance details` page. 3. Under the section `Service Account`, take note of the service account 4. Run the commands locally for your operating system that are located at the docs/troubleshooting/vm-manager/verify-setup#service-account-enabled reference located at the bottom of this page. They should return the name of your service account. **Determine if Instances can connect to public update hosting** Each type of operating system has its own update process. You will need to determine on each operating system that it can reach the update servers via its network connection. The VM Manager doesn't host the updates, it will only allow you to centrally issue a command to each VM to update. **Determine if OS Config API is Enabled for the Project** 1. In each project you wish to enable run the following command gcloud services list 2. If osconfig.googleapis.com is in the left hand column it is enabled for this project. **Determine if VM Manager is Enabled for the Project** 1. Within the project run the following command: gcloud compute instances os-inventory describe VM-NAME --zone=ZONE The output will look like INSTANCE_ID INSTANCE_NAME OS OSCONFIG_AGENT_VERSION UPDATE_TIME 29255009728795105 centos7 CentOS Linux 7 (Core) 20210217.00-g1.el7 2021-04-12T22:19:36.559Z 5138980234596718741 rhel-8 Red Hat Enterprise Linux 8.3 (Ootpa) 20210316.00-g1.el8 2021-09-16T17:19:24Z 7127836223366142250 windows Microsoft Windows Server 2019 Datacenter 20210316.00.0+win@1 2021-09-16T17:13:18Z **Determine if VM Instances have correct metadata tags for OSConfig parsing** 1. Select the project you want to view tagging in. **From Google Cloud Console** 1. From the main Google Cloud console, open the hamburger menu in the top left. Mouse over Computer Engine to expand the menu next to it. 2. Under the Settings heading, select Metadata. 3. In this view there will be a list of the project wide metadata tags for Vms. Verify a tag of ‘enable-osconfig’ is in this list and it is set to ‘true’. **From Command Line** Run the following command to view instance data gcloud compute instances list --format=table(name,status,tags.list()) On each instance it should have a tag of ‘enable-osconfig’ set to ‘true’ **Determine if the Operating System of VM Instances have the local OS-Config Agent running** There is no way to determine this from the Google Cloud CLI. The best way is to run the the commands inside the operating system located at 'Check OS-Config agent is installed and running' at the /docs/troubleshooting/vm-manager/verify-setup reference at the bottom of the page. If you initialized your VM instance with a Google Supplied OS Image with a build date of later than v20200114 it will have the service installed. You should still determine its status. **Verify the service account you have setup for the project in Recommendation 4.1 is running** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on each instance name to go to its `VM instance details` page. 3. Under the section `Service Account`, take note of the service account 4. View the compute/docs/troubleshooting/vm-manager/verify-setup#service-account-enabled resource at the bottom of the page for operating system specific commands to run locally. **Determine if Instances can connect to public update hosting** Linux Debian Based Operating Systems sudo apt update The output should have a numbered list of lines with Hit: URL of updates. Redhat Based Operating Systems yum check-update The output should show a list of packages that have updates available. Windows ping http://windowsupdate.microsoft.com/ The ping should successfully be delivered and received.", + "AdditionalInformation": "This is not your only solution to handle updates. This is a Google Cloud specific recommendation to leverage a resource to solve the need for comprehensive update procedures and policy. If you have a solution already in place you do not need to make the switch. There are also further resources that would be out of the scope of this recommendation. If you need to allow your VMs to access public hosted updates, please see the reference to setup NAT or Private Google Access.", + "References": "https://cloud.google.com/compute/docs/manage-os:https://cloud.google.com/compute/docs/os-patch-management:https://cloud.google.com/compute/docs/vm-manager:https://cloud.google.com/compute/docs/images/os-details#vm-manager:https://cloud.google.com/compute/docs/vm-manager#pricing:https://cloud.google.com/compute/docs/troubleshooting/vm-manager/verify-setup:https://cloud.google.com/compute/docs/instances/view-os-details#view-data-tools:https://cloud.google.com/compute/docs/os-patch-management/create-patch-job:https://cloud.google.com/nat/docs/set-up-network-address-translation:https://cloud.google.com/vpc/docs/configure-private-google-access:https://workbench.cisecurity.org/sections/811638/recommendations/1334335:https://cloud.google.com/compute/docs/manage-os#agent-install:https://cloud.google.com/compute/docs/troubleshooting/vm-manager/verify-setup#service-account-enabled:https://cloud.google.com/compute/docs/os-patch-management#use-dashboard:https://cloud.google.com/compute/docs/troubleshooting/vm-manager/verify-setup#metadata-enabled", + "DefaultValue": "By default most operating systems and programs do not update themselves. The Google Cloud VM Manager which is a dependency of the OS Patch management feature is installed on Google Built OS images with a build date of v20200114 or later. The VM manager is not enabled in a project by default and will need to be setup.", + "SubSection": null + } + ] + }, + { + "Id": "5.1", + "Description": "Ensure That Cloud Storage Bucket Is Not Anonymously or Publicly Accessible", + "Checks": [ + "cloudstorage_bucket_public_access" + ], + "Attributes": [ + { + "Section": "5 Storage", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that IAM policy on Cloud Storage bucket does not allows anonymous or public access.", + "RationaleStatement": "Allowing anonymous or public access grants permissions to anyone to access bucket content. Such access might not be desired if you are storing any sensitive data. Hence, ensure that anonymous or public access to a bucket is not allowed.", + "ImpactStatement": "No storage buckets would be publicly accessible. You would have to explicitly administer bucket access.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `Storage browser` by visiting [https://console.cloud.google.com/storage/browser](https://console.cloud.google.com/storage/browser). 2. Click on the bucket name to go to its `Bucket details` page. 3. Click on the `Permissions` tab. 4. Click `Delete` button in front of `allUsers` and `allAuthenticatedUsers` to remove that particular role assignment. **From Google Cloud CLI** Remove `allUsers` and `allAuthenticatedUsers` access. gsutil iam ch -d allUsers gs://BUCKET_NAME gsutil iam ch -d allAuthenticatedUsers gs://BUCKET_NAME **Prevention:** You can prevent Storage buckets from becoming publicly accessible by setting up the `Domain restricted sharing` organization policy at:[ https://console.cloud.google.com/iam-admin/orgpolicies/iam-allowedPolicyMemberDomains ](https://console.cloud.google.com/iam-admin/orgpolicies/iam-allowedPolicyMemberDomains).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Storage browser` by visiting [https://console.cloud.google.com/storage/browser](https://console.cloud.google.com/storage/browser). 2. Click on each bucket name to go to its `Bucket details` page. 3. Click on the `Permissions` tab. 4. Ensure that `allUsers` and `allAuthenticatedUsers` are not in the `Members` list. **From Google Cloud CLI** 1. List all buckets in a project gsutil ls 2. Check the IAM Policy for each bucket: gsutil iam get gs://BUCKET_NAME No role should contain `allUsers` and/or `allAuthenticatedUsers` as a member. **Using Rest API** 1. List all buckets in a project Get https://www.googleapis.com/storage/v1/b?project= 2. Check the IAM Policy for each bucket GET https://www.googleapis.com/storage/v1/b//iam No role should contain `allUsers` and/or `allAuthenticatedUsers` as a member.", + "AdditionalInformation": "To implement Access restrictions on buckets, configuring Bucket IAM is preferred way than configuring Bucket ACL. On GCP console, Edit Permissions for bucket exposes IAM configurations only. Bucket ACLs are configured automatically as per need in order to implement/support User enforced Bucket IAM policy. In-case administrator changes bucket ACL using command-line(gsutils)/API bucket IAM also gets updated automatically.", + "References": "https://cloud.google.com/storage/docs/access-control/iam-reference:https://cloud.google.com/storage/docs/access-control/making-data-public:https://cloud.google.com/storage/docs/gsutil/commands/iam", + "DefaultValue": "By Default, Storage buckets are not publicly shared.", + "SubSection": null + } + ] + }, + { + "Id": "5.2", + "Description": "Ensure That Cloud Storage Buckets Have Uniform Bucket-Level Access Enabled", + "Checks": [ + "cloudstorage_bucket_uniform_bucket_level_access" + ], + "Attributes": [ + { + "Section": "5 Storage", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that uniform bucket-level access is enabled on Cloud Storage buckets.", + "RationaleStatement": "It is recommended to use uniform bucket-level access to unify and simplify how you grant access to your Cloud Storage resources. Cloud Storage offers two systems for granting users permission to access your buckets and objects: Cloud Identity and Access Management (Cloud IAM) and Access Control Lists (ACLs). These systems act in parallel - in order for a user to access a Cloud Storage resource, only one of the systems needs to grant the user permission. Cloud IAM is used throughout Google Cloud and allows you to grant a variety of permissions at the bucket and project levels. ACLs are used only by Cloud Storage and have limited permission options, but they allow you to grant permissions on a per-object basis. In order to support a uniform permissioning system, Cloud Storage has uniform bucket-level access. Using this feature disables ACLs for all Cloud Storage resources: access to Cloud Storage resources then is granted exclusively through Cloud IAM. Enabling uniform bucket-level access guarantees that if a Storage bucket is not publicly accessible, no object in the bucket is publicly accessible either.", + "ImpactStatement": "If you enable uniform bucket-level access, you revoke access from users who gain their access solely through object ACLs. Certain Google Cloud services, such as Stackdriver, Cloud Audit Logs, and Datastore, cannot export to Cloud Storage buckets that have uniform bucket-level access enabled.", + "RemediationProcedure": "**From Google Cloud Console** 1. Open the Cloud Storage browser in the Google Cloud Console by visiting: [https://console.cloud.google.com/storage/browser](https://console.cloud.google.com/storage/browser) 2. In the list of buckets, click on the name of the desired bucket. 3. Select the `Permissions` tab near the top of the page. 4. In the text box that starts with `This bucket uses fine-grained access control...`, click `Edit`. 5. In the pop-up menu that appears, select `Uniform`. 6. Click `Save`. **From Google Cloud CLI** Use the on option in a uniformbucketlevelaccess set command: gsutil uniformbucketlevelaccess set on gs://BUCKET_NAME/ **Prevention** You can set up an Organization Policy to enforce that any new bucket has uniform bucket level access enabled. Learn more at: [https://cloud.google.com/storage/docs/setting-org-policies#uniform-bucket](https://cloud.google.com/storage/docs/setting-org-policies#uniform-bucket)", + "AuditProcedure": "**From Google Cloud Console** 1. Open the Cloud Storage browser in the Google Cloud Console by visiting: [https://console.cloud.google.com/storage/browser](https://console.cloud.google.com/storage/browser) 2. For each bucket, make sure that `Access control` column has the value `Uniform`. **From Google Cloud CLI** 1. List all buckets in a project gsutil ls 2. For each bucket, verify that uniform bucket-level access is enabled. gsutil uniformbucketlevelaccess get gs://BUCKET_NAME/ If uniform bucket-level access is enabled, the response looks like: Uniform bucket-level access setting for gs://BUCKET_NAME/: Enabled: True LockedTime: LOCK_DATE ", + "AdditionalInformation": "Uniform bucket-level access can no longer be disabled if it has been active on a bucket for 90 consecutive days.", + "References": "https://cloud.google.com/storage/docs/uniform-bucket-level-access:https://cloud.google.com/storage/docs/using-uniform-bucket-level-access:https://cloud.google.com/storage/docs/setting-org-policies#uniform-bucket", + "DefaultValue": "By default, Cloud Storage buckets do not have uniform bucket-level access enabled.", + "SubSection": null + } + ] + }, + { + "Id": "6.1.1", + "Description": "Ensure That a MySQL Instance Does Not Allow Anyone To Connect With Administrative Privileges", + "Checks": [], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.1 MySQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "It is recommended to set a password for the administrative user (`root` by default) to prevent unauthorized access to the SQL database instances. This recommendation is applicable only for MySQL Instances. PostgreSQL does not offer any setting for No Password from the cloud console.", + "RationaleStatement": "At the time of MySQL Instance creation, not providing an administrative password allows anyone to connect to the SQL database instance with administrative privileges. The root password should be set to ensure only authorized users have these privileges.", + "ImpactStatement": "Connection strings for administrative clients need to be reconfigured to use a password.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Platform Console using `https://console.cloud.google.com/sql/` 2. Select the instance to open its Overview page. 3. Select `Access Control > Users`. 4. Click the `More actions icon` for the user to be updated. 5. Select `Change password`, specify a `New password`, and click `OK`. **From Google Cloud CLI** 1. Set a password to a MySql instance: gcloud sql users set-password root --host= --instance= --prompt-for-password 2. A prompt will appear, requiring the user to enter a password: Instance Password: 3. With a successful password configured, the following message should be seen: Updating Cloud SQL user...done. ", + "AuditProcedure": "**From Google Cloud CLI** 1. List All SQL database instances of type MySQL: gcloud sql instances list --filter='DATABASE_VERSION:MYSQL*' --project --format=(NAME,PRIMARY_ADDRESS) 2. For every MySQL instance try to connect using the `PRIMARY_ADDRESS`, if available: mysql -u root -h The command should return either an error message or a password prompt. Sample Error message: ERROR 1045 (28000): Access denied for user 'root'@'' (using password: NO) If a command produces the `mysql>` prompt, the MySQL instance allows anyone to connect with administrative privileges without needing a password. **Note:** The `No Password` setting is exposed only at the time of MySQL instance creation. Once the instance is created, the Google Cloud Platform Console does not expose the set to confirm whether a password for an administrative user is set to a MySQL instance.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/sql/docs/mysql/create-manage-users:https://cloud.google.com/sql/docs/mysql/create-instance", + "DefaultValue": "From the Google Cloud Platform Console, the `Create Instance` workflow enforces the rule to enter the root password unless the option `No Password` is selected explicitly." + } + ] + }, + { + "Id": "6.1.2", + "Description": "Ensure ‘Skip_show_database’ Database Flag for Cloud SQL MySQL Instance Is Set to ‘On’", + "Checks": [ + "cloudsql_instance_mysql_skip_show_database_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.1 MySQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to set `skip_show_database` database flag for Cloud SQL Mysql instance to `on`", + "RationaleStatement": "`skip_show_database` database flag prevents people from using the SHOW DATABASES statement if they do not have the SHOW DATABASES privilege. This can improve security if you have concerns about users being able to see databases belonging to other users. Its effect depends on the SHOW DATABASES privilege: If the variable value is ON, the SHOW DATABASES statement is permitted only to users who have the SHOW DATABASES privilege, and the statement displays all database names. If the value is OFF, SHOW DATABASES is permitted to all users, but displays the names of only those databases for which the user has the SHOW DATABASES or other privilege. This recommendation is applicable to Mysql database instances.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the Mysql instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `skip_show_database` from the drop-down menu, and set its value to `on`. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. List all Cloud SQL database Instances gcloud sql instances list 2. Configure the `skip_show_database` database flag for every Cloud SQL Mysql database instance using the below command. gcloud sql instances patch --database-flags skip_show_database=on **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `skip_show_database` that has been set is listed under the `Database flags` section. **From Google Cloud CLI** 1. List all Cloud SQL database Instances gcloud sql instances list 2. Ensure the below command returns `on` for every Cloud SQL Mysql database instance gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==skip_show_database)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/mysql/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag restarts the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/mysql/flags:https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_skip_show_database", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.1.3", + "Description": "Ensure That the ‘Local_infile’ Database Flag for a Cloud SQL MySQL Instance Is Set to ‘Off’", + "Checks": [ + "cloudsql_instance_mysql_local_infile_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.1 MySQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to set the `local_infile` database flag for a Cloud SQL MySQL instance to `off`.", + "RationaleStatement": "The `local_infile` flag controls the server-side LOCAL capability for LOAD DATA statements. Depending on the `local_infile` setting, the server refuses or permits local data loading by clients that have LOCAL enabled on the client side. To explicitly cause the server to refuse LOAD DATA LOCAL statements (regardless of how client programs and libraries are configured at build time or runtime), start mysqld with local_infile disabled. local_infile can also be set at runtime. Due to security issues associated with the `local_infile` flag, it is recommended to disable it. This recommendation is applicable to MySQL database instances.", + "ImpactStatement": "Disabling `local_infile` makes the server refuse local data loading by clients that have LOCAL enabled on the client side.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the MySQL instance where the database flag needs to be enabled. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `local_infile` from the drop-down menu, and set its value to `off`. 6. Click `Save`. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. List all Cloud SQL database instances using the following command: gcloud sql instances list 2. Configure the `local_infile` database flag for every Cloud SQL Mysql database instance using the below command: gcloud sql instances patch --database-flags local_infile=off **Note**: This command will overwrite all database flags that were previously set. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `local_infile` that has been set is listed under the `Database flags` section. **From Google Cloud CLI** 1. List all Cloud SQL database instances: gcloud sql instances list 2. Ensure the below command returns `off` for every Cloud SQL MySQL database instance. gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==local_infile)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require the instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/mysql/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines.", + "References": "https://cloud.google.com/sql/docs/mysql/flags:https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_local_infile:https://dev.mysql.com/doc/refman/5.7/en/load-data-local.html", + "DefaultValue": "By default `local_infile` is `on`." + } + ] + }, + { + "Id": "6.2.1", + "Description": "Ensure ‘Log_error_verbosity’ Database Flag for Cloud SQL PostgreSQL Instance Is Set to ‘DEFAULT’ or Stricter", + "Checks": [ + "cloudsql_instance_postgres_log_error_verbosity_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "The `log_error_verbosity` flag controls the verbosity/details of messages logged. Valid values are: - `TERSE` - `DEFAULT` - `VERBOSE` `TERSE` excludes the logging of `DETAIL`, `HINT`, `QUERY`, and `CONTEXT` error information. `VERBOSE` output includes the `SQLSTATE` error code, source code file name, function name, and line number that generated the error. Ensure an appropriate value is set to 'DEFAULT' or stricter.", + "RationaleStatement": "Auditing helps in troubleshooting operational problems and also permits forensic analysis. If `log_error_verbosity` is not set to the correct value, too many details or too few details may be logged. This flag should be configured with a value of 'DEFAULT' or stricter. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Turning on logging will increase the required storage over time. Mismanaged logs may cause your storage costs to increase. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting https://console.cloud.google.com/sql/instances. 2. Select the PostgreSQL instance for which you want to enable the database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `log_error_verbosity` from the drop-down menu and set appropriate value. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the log_error_verbosity database flag for every Cloud SQL PosgreSQL database instance using the below command. gcloud sql instances patch INSTANCE_NAME --database-flags log_error_verbosity= **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Go to `Configuration` card 4. Under `Database flags`, check the value of `log_error_verbosity` flag is set to 'DEFAULT' or stricter. **From Google Cloud CLI** 1. Use the below command for every Cloud SQL PostgreSQL database instance to verify the value of `log_error_verbosity` gcloud sql instances describe [INSTANCE_NAME] --format=json | jq '.settings.databaseFlags[] | select(.name==log_error_verbosity)|.value' In the output, database flags are listed under the settings as the collection databaseFlags.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-ERROR-VERBOSITY", + "DefaultValue": "By default `log_error_verbosity` is `DEFAULT`." + } + ] + }, + { + "Id": "6.2.2", + "Description": "Ensure That the ‘Log_connections’ Database Flag for Cloud SQL PostgreSQL Instance Is Set to ‘On’", + "Checks": [ + "cloudsql_instance_postgres_log_connections_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enabling the `log_connections` setting causes each attempted connection to the server to be logged, along with successful completion of client authentication. This parameter cannot be changed after the session starts.", + "RationaleStatement": "PostgreSQL does not log attempted connections by default. Enabling the `log_connections` setting will create log entries for each attempted connection as well as successful completion of client authentication which can be useful in troubleshooting issues and to determine any unusual connection attempts to the server. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Turning on logging will increase the required storage over time. Mismanaged logs may cause your storage costs to increase. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting https://console.cloud.google.com/sql/instances. 2. Select the PostgreSQL instance for which you want to enable the database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `log_connections` from the drop-down menu and set the value as `on`. 6. Click `Save`. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `log_connections` database flag for every Cloud SQL PosgreSQL database instance using the below command. gcloud sql instances patch --database-flags log_connections=on **Note**: This command will overwrite all previously set database flags. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page. 3. Go to the `Configuration` card. 4. Under `Database flags`, check the value of `log_connections` flag to determine if it is configured as expected. **From Google Cloud CLI** 1. Ensure the below command returns `on` for every Cloud SQL PostgreSQL database instance: gcloud sql instances describe [INSTANCE_NAME] --format=json | jq '.settings.databaseFlags[] | select(.name==log_connections)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see the Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-CONNECTIONS", + "DefaultValue": "By default `log_connections` is `off`." + } + ] + }, + { + "Id": "6.2.3", + "Description": "Ensure That the ‘Log_disconnections’ Database Flag for Cloud SQL PostgreSQL Instance Is Set to ‘On’", + "Checks": [ + "cloudsql_instance_postgres_log_disconnections_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enabling the `log_disconnections` setting logs the end of each session, including the session duration.", + "RationaleStatement": "PostgreSQL does not log session details such as duration and session end by default. Enabling the `log_disconnections` setting will create log entries at the end of each session which can be useful in troubleshooting issues and determine any unusual activity across a time period. The `log_disconnections` and `log_connections` work hand in hand and generally, the pair would be enabled/disabled together. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Turning on logging will increase the required storage over time. Mismanaged logs may cause your storage costs to increase. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the PostgreSQL instance where the database flag needs to be enabled. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `log_disconnections` from the drop-down menu and set the value as `on`. 6. Click `Save`. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `log_disconnections` database flag for every Cloud SQL PosgreSQL database instance using the below command: gcloud sql instances patch --database-flags log_disconnections=on **Note**: This command will overwrite all previously set database flags. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Go to the `Configuration` card. 4. Under `Database flags`, check the value of `log_disconnections` flag is configured as expected. **From Google Cloud CLI** 1. Ensure the below command returns `on` for every Cloud SQL PostgreSQL database instance: gcloud sql instances list --format=json | jq '.[].settings.databaseFlags[] | select(.name==log_disconnections)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-DISCONNECTIONS", + "DefaultValue": "By default `log_disconnections` is off." + } + ] + }, + { + "Id": "6.2.4", + "Description": "Ensure ‘Log_statement’ Database Flag for Cloud SQL PostgreSQL Instance Is Set Appropriately", + "Checks": [ + "cloudsql_instance_postgres_log_statement_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "The value of `log_statement` flag determined the SQL statements that are logged. Valid values are: - `none` - `ddl` - `mod` - `all` The value `ddl` logs all data definition statements. The value `mod` logs all ddl statements, plus data-modifying statements. The statements are logged after a basic parsing is done and statement type is determined, thus this does not logs statements with errors. When using extended query protocol, logging occurs after an Execute message is received and values of the Bind parameters are included. A value of 'ddl' is recommended unless otherwise directed by your organization's logging policy.", + "RationaleStatement": "Auditing helps in forensic analysis. If `log_statement` is not set to the correct value, too many statements may be logged leading to issues in finding the relevant information from the logs, or too few statements may be logged with relevant information missing from the logs. Setting log_statement to align with your organization's security and logging policies facilitates later auditing and review of database activities. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Turning on logging will increase the required storage over time. Mismanaged logs may cause your storage costs to increase. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the PostgreSQL instance for which you want to enable the database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `log_statement` from the drop-down menu and set appropriate value. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `log_statement` database flag for every Cloud SQL PosgreSQL database instance using the below command. gcloud sql instances patch --database-flags log_statement= **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Go to `Configuration` card 4. Under `Database flags`, check the value of `log_statement` flag is set to appropriately. **From Google Cloud CLI** 1. Use the below command for every Cloud SQL PostgreSQL database instance to verify the value of `log_statement` gcloud sql instances list --format=json | jq '.[].settings.databaseFlags[] | select(.name==log_statement)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-STATEMENT", + "DefaultValue": "`none`" + } + ] + }, + { + "Id": "6.2.5", + "Description": "Ensure that the ‘Log_min_messages’ Flag for a Cloud SQL PostgreSQL Instance is set at minimum to 'Warning'", + "Checks": [ + "cloudsql_instance_postgres_log_min_messages_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The `log_min_messages` flag defines the minimum message severity level that is considered as an error statement. Messages for error statements are logged with the SQL statement. Valid values include (from lowest to highest severity) `DEBUG5`, `DEBUG4`, `DEBUG3`, `DEBUG2`, `DEBUG1`, `INFO`, `NOTICE`, `WARNING`, `ERROR`, `LOG`, `FATAL`, and `PANIC`. Each severity level includes the subsequent levels mentioned above. ERROR is considered the best practice setting. Changes should only be made in accordance with the organization's logging policy.", + "RationaleStatement": "Auditing helps in troubleshooting operational problems and also permits forensic analysis. If `log_min_messages` is not set to the correct value, messages may not be classified as error messages appropriately. Setting the threshold to 'Warning' will log messages for the most needed error messages. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Setting the threshold too low will might result in increased log storage size and length, making it difficult to find actual errors. Higher severity levels may cause errors needed to troubleshoot to not be logged. An organization will need to decide their own threshold for logging `log_min_messages` flag. **Note**: To effectively turn off logging failing statements, set this parameter to PANIC.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances) 2. Select the PostgreSQL instance for which you want to enable the database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `log_min_messages` from the drop-down menu and set appropriate value. 6. Click `Save` to save the changes. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `log_min_messages` database flag for every Cloud SQL PosgreSQL database instance using the below command. gcloud sql instances patch --database-flags log_min_messages= **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page. 3. Go to the `Configuration` card. 4. Under `Database flags`, check the value of `log_min_messages` flag is set to `warning` or higher (WARNING|ERROR|LOG|FATAL|PANIC). **From Google Cloud CLI** 1. Use the below command for every Cloud SQL PostgreSQL database instance to verify that the value of `log_min_messages` is set to `warning` or higher . gcloud sql instances describe [INSTANCE_NAME] --format=json | jq '.settings.databaseFlags[] | select(.name==log_min_messages)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: Some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-MIN-MESSAGES", + "DefaultValue": "By default `log_min_messages` is `ERROR`." + } + ] + }, + { + "Id": "6.2.6", + "Description": "Ensure ‘Log_min_error_statement’ Database Flag for Cloud SQL PostgreSQL Instance Is Set to ‘Error’ or Stricter", + "Checks": [ + "cloudsql_instance_postgres_log_min_error_statement_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The `log_min_error_statement` flag defines the minimum message severity level that are considered as an error statement. Messages for error statements are logged with the SQL statement. Valid values include (from lowest to highest severity) `DEBUG5`, `DEBUG4`, `DEBUG3`, `DEBUG2`, `DEBUG1`, `INFO`, `NOTICE`, `WARNING`, `ERROR`, `LOG`, `FATAL`, and `PANIC`. Each severity level includes the subsequent levels mentioned above. Ensure a value of `ERROR` or stricter is set.", + "RationaleStatement": "Auditing helps in troubleshooting operational problems and also permits forensic analysis. If `log_min_error_statement` is not set to the correct value, messages may not be classified as error messages appropriately. Considering general log messages as error messages would make is difficult to find actual errors and considering only stricter severity levels as error messages may skip actual errors to log their SQL statements. The `log_min_error_statement` flag should be set to `ERROR` or stricter. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Turning on logging will increase the required storage over time. Mismanaged logs may cause your storage costs to increase. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the PostgreSQL instance for which you want to enable the database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `log_min_error_statement` from the drop-down menu and set appropriate value. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `log_min_error_statement` database flag for every Cloud SQL PosgreSQL database instance using the below command. gcloud sql instances patch --database-flags log_min_error_statement= **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Go to `Configuration` card 4. Under `Database flags`, check the value of `log_min_error_statement` flag is configured as to `ERROR` or stricter. **From Google Cloud CLI** 1. Use the below command for every Cloud SQL PostgreSQL database instance to verify the value of `log_min_error_statement` is set to `ERROR` or stricter. gcloud sql instances describe --format=json | jq '.[].settings.databaseFlags[] | select(.name==log_min_error_statement)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-MIN-ERROR-STATEMENT", + "DefaultValue": "By default `log_min_error_statement` is `ERROR`." + } + ] + }, + { + "Id": "6.2.7", + "Description": "Ensure That the ‘Log_min_duration_statement’ Database Flag for Cloud SQL PostgreSQL Instance Is Set to '-1' (Disabled)", + "Checks": [ + "cloudsql_instance_postgres_log_min_duration_statement_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The `log_min_duration_statement` flag defines the minimum amount of execution time of a statement in milliseconds where the total duration of the statement is logged. Ensure that `log_min_duration_statement` is disabled, i.e., a value of `-1` is set.", + "RationaleStatement": "Logging SQL statements may include sensitive information that should not be recorded in logs. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Turning on logging will increase the required storage over time. Mismanaged logs may cause your storage costs to increase. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the PostgreSQL instance where the database flag needs to be enabled. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `log_min_duration_statement` from the drop-down menu and set a value of `-1`. 6. Click `Save`. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. List all Cloud SQL database instances using the following command: gcloud sql instances list 2. Configure the `log_min_duration_statement` flag for every Cloud SQL PosgreSQL database instance using the below command: gcloud sql instances patch --database-flags log_min_duration_statement=-1 **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page. 3. Go to the `Configuration` card. 4. Under `Database flags`, check that the value of `log_min_duration_statement` flag is set to `-1`. **From Google Cloud CLI** 1. Use the below command for every Cloud SQL PostgreSQL database instance to verify the value of `log_min_duration_statement` is set to `-1`. gcloud sql instances describe --format=json| jq '.settings.databaseFlags[] | select(.name==log_min_duration_statement)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: Some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-MIN-DURATION-STATEMENT", + "DefaultValue": "By default `log_min_duration_statement` is `-1`." + } + ] + }, + { + "Id": "6.2.8", + "Description": "Ensure That 'cloudsql.enable_pgaudit' Database Flag for each Cloud Sql Postgresql Instance Is Set to 'on' For Centralized Logging", + "Checks": [ + "cloudsql_instance_postgres_enable_pgaudit_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure `cloudsql.enable_pgaudit` database flag for Cloud SQL PostgreSQL instance is set to `on` to allow for centralized logging.", + "RationaleStatement": "As numerous other recommendations in this section consist of turning on flags for logging purposes, your organization will need a way to manage these logs. You may have a solution already in place. If you do not, consider installing and enabling the open source pgaudit extension within PostgreSQL and enabling its corresponding flag of `cloudsql.enable_pgaudit`. This flag and installing the extension enables database auditing in PostgreSQL through the open-source pgAudit extension. This extension provides detailed session and object logging to comply with government, financial, & ISO standards and provides auditing capabilities to mitigate threats by monitoring security events on the instance. Enabling the flag and settings later in this recommendation will send these logs to Google Logs Explorer so that you can access them in a central location. to This recommendation is applicable only to PostgreSQL database instances.", + "ImpactStatement": "Enabling the pgAudit extension can lead to increased data storage requirements and to ensure durability of pgAudit log records in the event of unexpected storage issues, it is recommended to enable the `Enable automatic storage increases` setting on the instance. Enabling flags via the command line will also overwrite all existing flags, so you should apply all needed flags in the CLI command. Also flags may require a restart of the server to be implemented or will break existing functionality so update your servers at a time of low usage.", + "RemediationProcedure": "**Initialize the pgAudit flag** **From Google Cloud Console** 1. Go to [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Overview` page. 3. Click `Edit`. 4. Scroll down and expand `Flags`. 5. To set a flag that has not been set on the instance before, click `Add item`. 6. Enter `cloudsql.enable_pgaudit` for the flag name and set the flag to `on`. 7. Click `Done`. 8. Click `Save` to update the configuration. 9. Confirm your changes under `Flags` on the `Overview` page. **From Google Cloud CLI** Run the below command by providing `` to enable `cloudsql.enable_pgaudit` flag. gcloud sql instances patch --database-flags cloudsql.enable_pgaudit=on Note: `RESTART` is required to get this configuration in effect. **Creating the extension** 1. Connect to the the server running PostgreSQL or through a SQL client of your choice. 2. Run the following command as a superuser. CREATE EXTENSION pgaudit; **Updating the previously created pgaudit.log flag for your Logging Needs** **From Console:** Note: there are multiple options here. This command will enable logging for all databases on a server. Please see the customizing database audit logging reference for more flag options. 1. Go to [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Overview` page. 3. Click `Edit`. 4. Scroll down and expand `Flags`. 5. To set a flag that has not been set on the instance before, click `Add item`. 6. Enter `pgaudit.log=all` for the flag name and set the flag to `on`. 7. Click `Done`. 8. Click `Save` to update the configuration. 9. Confirm your changes under `Flags` on the `Overview` page. **From Command Line:** Run the command gcloud sql instances patch --database-flags cloudsql.enable_pgaudit=on,pgaudit.log=all **Determine if logs are being sent to Logs Explorer** 1. From the Google Console home page, open the hamburger menu in the top left. 2. In the menu that pops open, scroll down to Logs Explorer under Operations. 3. In the query box, paste the following and search resource.type=cloudsql_database logName=projects//logs/cloudaudit.googleapis.com%2Fdata_access protoPayload.request.@type=type.googleapis.com/google.cloud.sql.audit.v1.PgAuditEntry If it returns any log sources, they are correctly setup.", + "AuditProcedure": "**Determining if the pgAudit Flag is set to 'on'** **From Google Cloud Console** 1. Go to [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Overview` page. 3. Click `Edit`. 4. Scroll down and expand `Flags`. 5. Ensure that `cloudsql.enable_pgaudit` flag is set to `on`. **From Google Cloud CLI** Run the command by providing ``. Ensure the value of the flag is `on`. gcloud sql instances describe --format=json | jq '.settings|.|.databaseFlags[]|select(.name==cloudsql.enable_pgaudit)|.value' **Determine if the pgAudit extension is installed** 1. Connect to the the server running PostgreSQL or through a SQL client of your choice. 2. Run the following command SELECT * FROM pg_extension; 3. If pgAudit is in this list. If so, it is installed. **Determine if Data Access Audit logs are enabled for your project and have sufficient privileges** 1. From the homepage open the hamburger menu in the top left. 2. Scroll down to `IAM & Admin`and hover over it. 3. In the menu that opens up, select `Audit Logs` 4. In the middle of the page, in the search box next to `filter` search for `Cloud Composer API` 5. Select it, and ensure that both 'Admin Read' and 'Data Read' are checked. **Determine if logs are being sent to Logs Explorer** 1. From the Google Console home page, open the hamburger menu in the top left. 2. In the menu that pops open, scroll down to Logs Explorer under Operations. 3. In the query box, paste the following and search resource.type=cloudsql_database logName=projects//logs/cloudaudit.googleapis.com%2Fdata_access protoPayload.request.@type=type.googleapis.com/google.cloud.sql.audit.v1.PgAuditEntry 4. If it returns any log sources, they are correctly setup.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. Note: Configuring the 'cloudsql.enable_pgaudit' database flag requires restarting the Cloud SQL PostgreSQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags#list-flags-postgres:https://cloud.google.com/sql/docs/postgres/pg-audit#enable-auditing-flag:https://cloud.google.com/sql/docs/postgres/pg-audit#customizing-database-audit-logging:https://cloud.google.com/logging/docs/audit/configure-data-access#config-console-enable", + "DefaultValue": "By default `cloudsql.enable_pgaudit` database flag is set to `off` and the extension is not enabled." + } + ] + }, + { + "Id": "6.3.1", + "Description": "Ensure 'external scripts enabled' Database Flag for Cloud SQL SQL Server Instance Is Set to 'off'", + "Checks": [ + "cloudsql_instance_sqlserver_external_scripts_enabled_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.3 SQL Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to set `external scripts enabled` database flag for Cloud SQL SQL Server instance to `off`", + "RationaleStatement": "`external scripts enabled` enable the execution of scripts with certain remote language extensions. This property is OFF by default. When Advanced Analytics Services is installed, setup can optionally set this property to true. As the External Scripts Enabled feature allows scripts external to SQL such as files located in an R library to be executed, which could adversely affect the security of the system, hence this should be disabled. This recommendation is applicable to SQL Server database instances.", + "ImpactStatement": "Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `external scripts enabled` from the drop-down menu, and set its value to `off`. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `external scripts enabled` database flag for every Cloud SQL SQL Server database instance using the below command. gcloud sql instances patch --database-flags external scripts enabled=off **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `external scripts enabled` that has been set is listed under the `Database flags` section. **From Google Cloud CLI** 1. Ensure the below command returns `off` for every Cloud SQL SQL Server database instance gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==external scripts enabled)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag restarts the Cloud SQL instance.", + "References": "https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/external-scripts-enabled-server-configuration-option?view=sql-server-ver15:https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/advanced-analytics/concepts/security?view=sql-server-ver15:https://www.stigviewer.com/stig/ms_sql_server_2016_instance/2018-03-09/finding/V-79347", + "DefaultValue": "By default `external scripts enabled` is `off`" + } + ] + }, + { + "Id": "6.3.2", + "Description": "Ensure 'cross db ownership chaining' Database Flag for Cloud SQL SQL Server Instance Is Set to 'off'", + "Checks": [ + "cloudsql_instance_sqlserver_cross_db_ownership_chaining_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.3 SQL Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to set `cross db ownership chaining` database flag for Cloud SQL SQL Server instance to `off`. This flag is deprecated for all SQL Server versions in CGP. Going forward, you can't set its value to on. However, if you have this flag enabled, we strongly recommend that you either remove the flag from your database or set it to off. For cross-database access, use the [Microsoft tutorial for signing stored procedures with a certificate](https://learn.microsoft.com/en-us/sql/relational-databases/tutorial-signing-stored-procedures-with-a-certificate?view=sql-server-ver16).", + "RationaleStatement": "Use the `cross db ownership` for chaining option to configure cross-database ownership chaining for an instance of Microsoft SQL Server. This server option allows you to control cross-database ownership chaining at the database level or to allow cross-database ownership chaining for all databases. Enabling `cross db ownership` is not recommended unless all of the databases hosted by the instance of SQL Server must participate in cross-database ownership chaining and you are aware of the security implications of this setting. This recommendation is applicable to SQL Server database instances.", + "ImpactStatement": "Updating flags may cause the database to restart. This may cause it to unavailable for a short amount of time, so this is best done at a time of low usage. You should also determine if the tables in your databases reference another table without using credentials for that database, as turning off cross database ownership will break this relationship.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `cross db ownership chaining` from the drop-down menu, and set its value to `off`. 6. Click `Save`. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `cross db ownership chaining` database flag for every Cloud SQL SQL Server database instance using the below command: gcloud sql instances patch --database-flags cross db ownership chaining=off **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**NOTE:** This flag is deprecated for all SQL Server versions. Going forward, you can't set its value to on. However, if you have this flag enabled it should be removed from your database or set to off. **From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console. 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `cross db ownership chaining` that has been set is listed under the `Database flags` section. **From Google Cloud CLI** 1. Ensure the below command returns `off` for every Cloud SQL SQL Server database instance: gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==cross db ownership chaining)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: Some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not restart the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/cross-db-ownership-chaining-server-configuration-option?view=sql-server-ver15", + "DefaultValue": "This flag is deprecated for all SQL Server versions. Going forward, you can't set its value to on." + } + ] + }, + { + "Id": "6.3.3", + "Description": "Ensure 'user Connections' Database Flag for Cloud SQL SQL Server Instance Is Set to a Non-limiting Value", + "Checks": [ + "cloudsql_instance_sqlserver_user_connections_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.3 SQL Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to check the `user connections` for a Cloud SQL SQL Server instance to ensure that it is not artificially limiting connections.", + "RationaleStatement": "The `user connections` option specifies the maximum number of simultaneous user connections that are allowed on an instance of SQL Server. The actual number of user connections allowed also depends on the version of SQL Server that you are using, and also the limits of your application or applications and hardware. SQL Server allows a maximum of 32,767 user connections. Because user connections is by default a self-configuring value, with SQL Server adjusting the maximum number of user connections automatically as needed, up to the maximum value allowable. For example, if only 10 users are logged in, 10 user connection objects are allocated. In most cases, you do not have to change the value for this option. The default is 0, which means that the maximum (32,767) user connections are allowed. However if there is a number defined here that limits connections, SQL Server will not allow anymore above this limit. If the connections are at the limit, any new requests will be dropped, potentially causing lost data or outages for those using the database.", + "ImpactStatement": "Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `user connections` from the drop-down menu, and set its value to your organization recommended value. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `user connections` database flag for every Cloud SQL SQL Server database instance using the below command. gcloud sql instances patch --database-flags user connections=[0-32,767] **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `user connections` listed under the `Database flags` section is 0. **From Google Cloud CLI** 1. Ensure the below command returns a value of 0, for every Cloud SQL SQL Server database instance. gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==user connections)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag restarts the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/configure-the-user-connections-server-configuration-option?view=sql-server-ver15:https://www.stigviewer.com/stig/ms_sql_server_2016_instance/2018-03-09/finding/V-79119", + "DefaultValue": "By default `user connections` is set to '0' which does not limit the number of connections, giving the server free reign to facilitate a max of 32,767 connections." + } + ] + }, + { + "Id": "6.3.4", + "Description": "Ensure 'user options' Database Flag for Cloud SQL SQL Server Instance Is Not Configured", + "Checks": [ + "cloudsql_instance_sqlserver_user_options_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.3 SQL Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The `user options` option specifies global defaults for all users. A list of default query processing options is established for the duration of a user's work session. The user options option allows you to change the default values of the SET options (if the server's default settings are not appropriate). A user can override these defaults by using the SET statement. You can configure user options dynamically for new logins. After you change the setting of user options, new login sessions use the new setting; current login sessions are not affected. This recommendation is applicable to SQL Server database instances.", + "RationaleStatement": "It is recommended that, `user options` database flag for Cloud SQL SQL Server instance should not be configured. A user can override these defaults set with `user options` by using the SET statement. Some of these features/options could adversely affect the security of the system if enabled.", + "ImpactStatement": "Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. Click the X next `user options` flag shown 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. List all Cloud SQL database Instances gcloud sql instances list 2. Clear the `user options` database flag for every Cloud SQL SQL Server database instance using either of the below commands. Clearing all flags to their default value gcloud sql instances patch --clear-database-flags OR To clear only `user options` database flag, configure the database flag by overriding the `user options`. Exclude `user options` flag and its value, and keep all other flags you want to configure. gcloud sql instances patch --database-flags [FLAG1=VALUE1,FLAG2=VALUE2] **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `user options` that has been set is not listed under the `Database flags` section. **From Google Cloud CLI** 1. Ensure the below command returns empty result for every Cloud SQL SQL Server database instance gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==user options)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not restart the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/configure-the-user-options-server-configuration-option?view=sql-server-ver15:https://www.stigviewer.com/stig/ms_sql_server_2016_instance/2018-03-09/finding/V-79335", + "DefaultValue": "By default 'user options' is not configured." + } + ] + }, + { + "Id": "6.3.5", + "Description": "Ensure 'remote access' Database Flag for Cloud SQL SQL Server Instance Is Set to 'off'", + "Checks": [ + "cloudsql_instance_sqlserver_remote_access_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.3 SQL Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to set `remote access` database flag for Cloud SQL SQL Server instance to `off`.", + "RationaleStatement": "The `remote access` option controls the execution of stored procedures from local or remote servers on which instances of SQL Server are running. This default value for this option is 1. This grants permission to run local stored procedures from remote servers or remote stored procedures from the local server. To prevent local stored procedures from being run from a remote server or remote stored procedures from being run on the local server, this must be disabled. The Remote Access option controls the execution of local stored procedures on remote servers or remote stored procedures on local server. 'Remote access' functionality can be abused to launch a Denial-of-Service (DoS) attack on remote servers by off-loading query processing to a target, hence this should be disabled. This recommendation is applicable to SQL Server database instances.", + "ImpactStatement": "Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `remote access` from the drop-down menu, and set its value to `off`. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `remote access` database flag for every Cloud SQL SQL Server database instance using the below command gcloud sql instances patch --database-flags remote access=off **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `remote access` that has been set is listed under the `Database flags` section. **From Google Cloud CLI** 1. Ensure the below command returns `off` for every Cloud SQL SQL Server database instance gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==remote access)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag restarts the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/configure-the-remote-access-server-configuration-option?view=sql-server-ver15:https://www.stigviewer.com/stig/ms_sql_server_2016_instance/2018-03-09/finding/V-79337", + "DefaultValue": "By default 'remote access' is 'on'." + } + ] + }, + { + "Id": "6.3.6", + "Description": "Ensure '3625 (trace flag)' Database Flag for all Cloud SQL SQL Server Instances Is Set to 'on'", + "Checks": [ + "cloudsql_instance_sqlserver_trace_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to set `3625 (trace flag)` database flag for Cloud SQL SQL Server instance to `on`.", + "RationaleStatement": "Microsoft SQL Trace Flags are frequently used to diagnose performance issues or to debug stored procedures or complex computer systems, but they may also be recommended by Microsoft Support to address behavior that is negatively impacting a specific workload. All documented trace flags and those recommended by Microsoft Support are fully supported in a production environment when used as directed. `3625(trace log)` Limits the amount of information returned to users who are not members of the sysadmin fixed server role, by masking the parameters of some error messages using '******'. Setting this in a Google Cloud flag for the instance allows for security through obscurity and prevents the disclosure of sensitive information, hence this is recommended to set this flag globally to on to prevent the flag having been left off, or changed by bad actors. This recommendation is applicable to SQL Server database instances.", + "ImpactStatement": "Changing flags on a database may cause it to be restarted. The best time to do this is at a time where there is low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `3625` from the drop-down menu, and set its value to `on`. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `3625` database flag for every Cloud SQL SQL Server database instance using the below command. gcloud sql instances patch --database-flags 3625=on **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `3625` that has been set is listed under the `Database flags` section. **From Google Cloud CLI** 1. Ensure the below command returns `on` for every Cloud SQL SQL Server database instance gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==3625)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag restarts the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/t-sql/database-console-commands/dbcc-traceon-trace-flags-transact-sql?view=sql-server-ver15#trace-flags:https://github.com/ktaranov/sqlserver-kit/blob/master/SQL%20Server%20Trace%20Flag.md", + "DefaultValue": "MS SQL Server implementations by default have trace flags, including the '3625' flag, turned off, as they are used for logging purposes.", + "SubSection": "6.3 SQL Server" + } + ] + }, + { + "Id": "6.3.7", + "Description": "Ensure 'contained database authentication' Database Flag for Cloud SQL SQL Server Instance Is Set to 'off'", + "Checks": [ + "cloudsql_instance_sqlserver_contained_database_authentication_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "A contained database includes all database settings and metadata required to define the database and has no configuration dependencies on the instance of the Database Engine where the database is installed. Users can connect to the database without authenticating a login at the Database Engine level. Isolating the database from the Database Engine makes it possible to easily move the database to another instance of SQL Server. Contained databases have some unique threats that should be understood and mitigated by SQL Server Database Engine administrators. Most of the threats are related to the USER WITH PASSWORD authentication process, which moves the authentication boundary from the Database Engine level to the database level, hence this is recommended not to enable this flag. This recommendation is applicable to SQL Server database instances.", + "RationaleStatement": "When contained databases are enabled, database users with the ALTER ANY USER permission, such as members of the db_owner and db_accessadmin database roles, can grant access to databases and by doing so, grant access to the instance of SQL Server. This means that control over access to the server is no longer limited to members of the sysadmin and securityadmin fixed server role, and logins with the server level CONTROL SERVER and ALTER ANY LOGIN permission. It is recommended to set `contained database authentication` database flag for Cloud SQL on the SQL Server instance to `off`.", + "ImpactStatement": "When `contained database authentication` is off (0) for the instance, contained databases cannot be created, or attached to the Database Engine. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. If the flag `contained database authentication` is present and its value is set to 'on', then change it to 'off'. 6. Click `Save`. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. If any Cloud SQL for SQL Server instance has the database flag `contained database authentication` set to 'on', then change it to 'off' using the below command: gcloud sql instances patch --database-flags contained database authentication=off **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Under the 'Database flags' section, if the database flag `contained database authentication` is present, then ensure that it is set to 'off'. **From Google Cloud CLI** 1. Ensure the below command returns `off` for any Cloud SQL for SQL Server database instance. gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==contained database authentication)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: Some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not restart the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/contained-database-authentication-server-configuration-option?view=sql-server-ver15:https://docs.microsoft.com/en-us/sql/relational-databases/databases/security-best-practices-with-contained-databases?view=sql-server-ver15", + "DefaultValue": "", + "SubSection": "6.3 SQL Server" + } + ] + }, + { + "Id": "6.4", + "Description": "Ensure That the Cloud SQL Database Instance Requires All Incoming Connections To Use SSL", + "Checks": [ + "cloudsql_instance_ssl_connections" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to enforce all incoming connections to SQL database instance to use SSL.", + "RationaleStatement": "SQL database connections if successfully trapped (MITM); can reveal sensitive data like credentials, database queries, query outputs etc. For security, it is recommended to always use SSL encryption when connecting to your instance. This recommendation is applicable for Postgresql, MySql generation 1, MySql generation 2 and SQL Server 2017 instances.", + "ImpactStatement": "After enforcing SSL requirement for connections, existing client will not be able to communicate with Cloud SQL database instance unless they use SSL encrypted connections to communicate to Cloud SQL database instance.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Click on an instance name to see its configuration overview. 3. In the left-side panel, select `Connections`. 3. In the `security` section, select SSL mode as `Allow only SSL connections`. 4. Under `Configure SSL server certificates` click `Create new certificate` and save the setting **From Google Cloud CLI** To enforce SSL encryption for an instance run the command: gcloud sql instances patch INSTANCE_NAME --ssl-mode= ENCRYPTED_ONLY Note: `RESTART` is required for type MySQL Generation 1 Instances (`backendType: FIRST_GEN`) to get this configuration in effect.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Click on an instance name to see its configuration overview. 3. In the left-side panel, select `Connections`. 3. In the `Security` section, ensure that `Allow only SSL connections` option is selected. **From Google Cloud CLI** 1. Get the detailed configuration for every SQL database instance using the following command: gcloud sql instances list --format=json Ensure that section `settings: ipConfiguration` has the parameter `sslMode` set to `ENCRYPTED_ONLY `.", + "AdditionalInformation": "By default `Settings: ipConfiguration` has no `authorizedNetworks` set/configured. In that case even if by default `sslMode` is not set, which is equivalent to `sslMode:ALLOW_UNENCRYPTED_AND_ENCRYPTED ` there is no risk as instance cannot be accessed outside of the network unless `authorizedNetworks` are configured. However, If default for `sslMode` is not updated to `ENCRYPTED_ONLY ` any `authorizedNetworks` created later on will not enforce SSL only connection.", + "References": "https://cloud.google.com/sql/docs/postgres/configure-ssl-instance/", + "DefaultValue": "By default parameter `settings: ipConfiguration: sslMode` is not set which is equivalent to `sslMode:ALLOW_UNENCRYPTED_AND_ENCRYPTED`.", + "SubSection": null + } + ] + }, + { + "Id": "6.5", + "Description": "Ensure That Cloud SQL Database Instances Do Not Implicitly Whitelist All Public IP Addresses", + "Checks": [ + "cloudsql_instance_public_access" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Database Server should accept connections only from trusted Network(s)/IP(s) and restrict access from public IP addresses.", + "RationaleStatement": "To minimize attack surface on a Database server instance, only trusted/known and required IP(s) should be white-listed to connect to it. An authorized network should not have IPs/networks configured to `0.0.0.0/0` which will allow access to the instance from anywhere in the world. Note that authorized networks apply only to instances with public IPs.", + "ImpactStatement": "The Cloud SQL database instance would not be available to public IP addresses.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Click the instance name to open its `Instance details` page. 3. Under the `Configuration` section click `Edit configurations` 4. Under `Configuration options` expand the `Connectivity` section. 5. Click the `delete` icon for the authorized network `0.0.0.0/0`. 6. Click `Save` to update the instance. **From Google Cloud CLI** Update the authorized network list by dropping off any addresses. gcloud sql instances patch --authorized-networks=IP_ADDR1,IP_ADDR2... **Prevention:** To prevent new SQL instances from being configured to accept incoming connections from any IP addresses, set up a `Restrict Authorized Networks on Cloud SQL instances` Organization Policy at: [https://console.cloud.google.com/iam-admin/orgpolicies/sql-restrictAuthorizedNetworks](https://console.cloud.google.com/iam-admin/orgpolicies/sql-restrictAuthorizedNetworks).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Click the instance name to open its `Instance details` page. 3. Under the `Configuration` section click `Edit configurations` 4. Under `Configuration options` expand the `Connectivity` section. 5. Ensure that no authorized network is configured to allow `0.0.0.0/0`. **From Google Cloud CLI** 1. Get detailed configuration for every Cloud SQL database instance. gcloud sql instances list --format=json Ensure that the section `settings: ipConfiguration : authorizedNetworks` does not have any parameter `value` containing `0.0.0.0/0`.", + "AdditionalInformation": "There is no IPv6 configuration found for Google cloud SQL server services.", + "References": "https://cloud.google.com/sql/docs/mysql/configure-ip:https://console.cloud.google.com/iam-admin/orgpolicies/sql-restrictAuthorizedNetworks:https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints:https://cloud.google.com/sql/docs/mysql/connection-org-policy", + "DefaultValue": "By default, authorized networks are not configured. Remote connection to Cloud SQL database instance is not possible unless authorized networks are configured.", + "SubSection": null + } + ] + }, + { + "Id": "6.6", + "Description": "Ensure Cloud SQL Database Instances Have IAM Database Authentication Enabled", + "Checks": [], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "It is recommended to enable IAM database authentication for Cloud SQL instances (PostgreSQL and MySQL) to eliminate static password-based authentication and instead use short-lived IAM tokens for database access. When enabled, database users can be created that authenticate via IAM, and connections use automatically-generated, short-lived tokens instead of static passwords. This recommendation is applicable to Cloud SQL for PostgreSQL and Cloud SQL for MySQL instances. Cloud SQL for SQL Server does not support IAM database authentication.", + "RationaleStatement": "IAM database authentication eliminates static database passwords by using short-lived IAM tokens for database access. This provides automatic credential rotation, centralized access control through Cloud IAM, and immediate revocation capabilities without password distribution across applications. Database access is correlated with GCP IAM principals in Cloud Logging, enabling better audit trails and attribution of database queries to specific service accounts or users. This recommendation is applicable to Cloud SQL for PostgreSQL and MySQL instances.", + "ImpactStatement": "Enabling IAM database authentication may require application changes, including updating connection methods and replacing password-based database users with IAM principals. Existing users and scripts that depend on static credentials may need to be reworked, and rollout should be coordinated carefully to avoid service disruption.", + "AuditProcedure": "**From Google Cloud CLI**\n\n1. List all Cloud SQL instances: `gcloud sql instances list`.\n2. For every PostgreSQL instance, verify the IAM authentication flag is enabled: `gcloud sql instances describe --format=\"json\" | jq '.settings.databaseFlags[] | select(.name==\"cloudsql.iam_authentication\")'`. It should return value `on`.\n3. For every MySQL instance, verify the flag `cloudsql_iam_authentication` is set to `on`. If the command returns no output, the flag is not set.", + "RemediationProcedure": "**From Google Cloud CLI**\n\n1. For PostgreSQL instances, enable IAM authentication: `gcloud sql instances patch --database-flags=cloudsql.iam_authentication=on`. For MySQL instances, use `cloudsql_iam_authentication=on`. Note: patching database flags overwrites previously set flags, so include all required flags.\n2. Create database users configured for IAM authentication using `gcloud sql users create ... --type=CLOUD_IAM_USER` (or `CLOUD_IAM_SERVICE_ACCOUNT`).\n3. Grant the necessary privileges to the database users via SQL GRANT commands.\n4. Ensure IAM principals have the roles `roles/cloudsql.client` and `roles/cloudsql.instanceUser`.\n5. Test the configuration by connecting using IAM authentication via the Cloud SQL Proxy.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, IAM database authentication is not enabled on Cloud SQL instances." + } + ] + }, + { + "Id": "6.7", + "Description": "Ensure That Cloud SQL Database Instances Do Not Have Public IPs", + "Checks": [ + "cloudsql_instance_public_access" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended to configure Second Generation Sql instance to use private IPs instead of public IPs.", + "RationaleStatement": "To lower the organization's attack surface, Cloud SQL databases should not have public IPs. Private IPs provide improved network security and lower latency for your application.", + "ImpactStatement": "Removing the public IP address on SQL instances may break some applications that relied on it for database connectivity.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console: [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances) 2. Click the instance name to open its Instance details page. 3. Select the `Connections` tab. 4. Deselect the `Public IP` checkbox. 5. Click `Save` to update the instance. **From Google Cloud CLI** 1. For every instance remove its public IP and assign a private IP instead: gcloud sql instances patch --network= --no-assign-ip 2. Confirm the changes using the following command:: gcloud sql instances describe **Prevention:** To prevent new SQL instances from getting configured with public IP addresses, set up a `Restrict Public IP access on Cloud SQL instances` Organization policy at: [https://console.cloud.google.com/iam-admin/orgpolicies/sql-restrictPublicIp](https://console.cloud.google.com/iam-admin/orgpolicies/sql-restrictPublicIp).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console: [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances) 2. Ensure that every instance has a private IP address and no public IP address configured. **From Google Cloud CLI** 1. List all Cloud SQL database instances using the following command: gcloud sql instances list 2. For every instance of type `instanceType: CLOUD_SQL_INSTANCE` with `backendType: SECOND_GEN`, get detailed configuration. Ignore instances of type `READ_REPLICA_INSTANCE` because these instances inherit their settings from the primary instance. Also, note that first generation instances cannot be configured to have a private IP address. gcloud sql instances describe 3. Ensure that the setting `ipAddresses` has an IP address configured of `type: PRIVATE` and has no IP address of `type: PRIMARY`. `PRIMARY` IP addresses are public addresses. An instance can have both a private and public address at the same time. Note also that you cannot use private IP with First Generation instances.", + "AdditionalInformation": "Replicas inherit their private IP status from their primary instance. You cannot configure a private IP directly on a replica.", + "References": "https://cloud.google.com/sql/docs/mysql/configure-private-ip:https://cloud.google.com/sql/docs/mysql/private-ip:https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints:https://console.cloud.google.com/iam-admin/orgpolicies/sql-restrictPublicIp", + "DefaultValue": "By default, Cloud Sql instances have a public IP.", + "SubSection": null + } + ] + }, + { + "Id": "6.8", + "Description": "Ensure That Cloud SQL Database Instances Are Configured With Automated Backups", + "Checks": [ + "cloudsql_instance_automated_backups" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to have all SQL database instances set to enable automated backups.", + "RationaleStatement": "Backups provide a way to restore a Cloud SQL instance to recover lost data or recover from a problem with that instance. Automated backups need to be set for any instance that contains data that should be protected from loss or damage. This recommendation is applicable for SQL Server, PostgreSql, MySql generation 1 and MySql generation 2 instances.", + "ImpactStatement": "Automated Backups will increase required size of storage and costs associated with it.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance where the backups need to be configured. 3. Click `Edit`. 4. In the `Backups` section, check `Enable automated backups', and choose a backup window. 5. Click `Save`. **From Google Cloud CLI** 1. List all Cloud SQL database instances using the following command: gcloud sql instances list --format=json | jq '. | map(select(.instanceType != READ_REPLICA_INSTANCE)) | .[].name' NOTE: gcloud command has been added with the filter to exclude read-replicas instances, as GCP do not provide Automated Backups for read-replica instances. 2. Enable `Automated backups` for every Cloud SQL database instance using the below command: gcloud sql instances patch --backup-start-time <[HH:MM]> The `backup-start-time` parameter is specified in 24-hour time, in the UTC±00 time zone, and specifies the start of a 4-hour backup window. Backups can start any time during the backup window.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Click the instance name to open its instance details page. 3. Go to the `Backups` menu. 4. Ensure that `Automated backups` is set to `Enabled` and `Backup time` is mentioned. **From Google Cloud CLI** 1. List all Cloud SQL database instances using the following command: gcloud sql instances list --format=json | jq '. | map(select(.instanceType != READ_REPLICA_INSTANCE)) | .[].name' NOTE: gcloud command has been added with the filter to exclude read-replicas instances, as GCP do not provide Automated Backups for read-replica instances. 2. Ensure that the below command returns `True` for every Cloud SQL database instance. gcloud sql instances describe --format=value('Enabled':settings.backupConfiguration.enabled) ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/sql/docs/mysql/backup-recovery/backups:https://cloud.google.com/sql/docs/postgres/backup-recovery/backups:https://cloud.google.com/sql/docs/sqlserver/backup-recovery/backups:https://cloud.google.com/sql/docs/mysql/backup-recovery/backing-up:https://cloud.google.com/sql/docs/postgres/backup-recovery/backing-up:https://cloud.google.com/sql/docs/sqlserver/backup-recovery/backing-up", + "DefaultValue": "By default, automated backups are not configured for Cloud SQL instances.", + "SubSection": null + } + ] + }, + { + "Id": "6.9", + "Description": "Ensure Cloud SQL Database Instances Have Deletion Protection Enabled", + "Checks": [], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that deletion protection is enabled on all Cloud SQL database instances to prevent accidental or unauthorized deletion. This setting safeguards critical databases by requiring explicit disabling of deletion protection before deletion, reducing the risk of data loss through human error or malicious activity.", + "RationaleStatement": "Cloud SQL instances without deletion protection can be permanently deleted with a single API call or console action, allowing compromised credentials, operational errors, or malicious insiders to cause catastrophic data loss. Deletion protection provides a critical safeguard against inadvertent or malicious deletion of production databases. By requiring deliberate action to disable deletion protection before an instance can be deleted, organizations mitigate risks associated with accidental data deletion and enhance the overall resilience of their data storage platform. This control is particularly important for production workloads where data loss could result in significant business disruption, regulatory compliance violations, and reputational damage.", + "ImpactStatement": "There is no performance impact to enabling deletion protection on Cloud SQL instances. The setting only affects delete operations. Administrators will need to explicitly disable deletion protection before deleting an instance, adding an additional step to the deletion workflow.", + "AuditProcedure": "**From Google Cloud CLI**\n\n1. List all Cloud SQL database instances to identify those without deletion protection: `gcloud sql instances list --format=\"table(name,settings.deletionProtectionEnabled)\"`.\n2. For each instance, verify deletion protection is enabled: `gcloud sql instances describe --format=\"value(settings.deletionProtectionEnabled)\"`. The command should return `True`. If it returns `False` or empty, deletion protection is not enabled.", + "RemediationProcedure": "**From Google Cloud CLI**\n\n1. To enable deletion protection on an existing Cloud SQL instance: `gcloud sql instances patch --deletion-protection`.\n2. To enable deletion protection when creating a new Cloud SQL instance, include the `--deletion-protection` flag in the `gcloud sql instances create` command.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, deletion protection is not enabled on Cloud SQL database instances." + } + ] + }, + { + "Id": "7.1", + "Description": "Ensure That BigQuery Datasets Are Not Anonymously or Publicly Accessible", + "Checks": [ + "bigquery_dataset_public_access" + ], + "Attributes": [ + { + "Section": "7 BigQuery", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that the IAM policy on BigQuery datasets does not allow anonymous and/or public access.", + "RationaleStatement": "Granting permissions to `allUsers` or `allAuthenticatedUsers` allows anyone to access the dataset. Such access might not be desirable if sensitive data is being stored in the dataset. Therefore, ensure that anonymous and/or public access to a dataset is not allowed.", + "ImpactStatement": "The dataset is not publicly accessible. Explicit modification of IAM privileges would be necessary to make them publicly accessible.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `BigQuery` by visiting: [https://console.cloud.google.com/bigquery](https://console.cloud.google.com/bigquery). 2. Select the dataset from 'Resources'. 3. Click `SHARING` near the right side of the window and select `Permissions`. 4. Review each attached role. 5. Click the delete icon for each member `allUsers` or `allAuthenticatedUsers`. On the popup click `Remove`. **From Google Cloud CLI** List the name of all datasets. bq ls Retrieve the data set details: bq show --format=prettyjson PROJECT_ID:DATASET_NAME > PATH_TO_FILE In the access section of the JSON file, update the dataset information to remove all roles containing `allUsers` or `allAuthenticatedUsers`. Update the dataset: bq update --source PATH_TO_FILE PROJECT_ID:DATASET_NAME **Prevention:** You can prevent Bigquery dataset from becoming publicly accessible by setting up the `Domain restricted sharing` organization policy at: https://console.cloud.google.com/iam-admin/orgpolicies/iam-allowedPolicyMemberDomains .", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `BigQuery` by visiting: [https://console.cloud.google.com/bigquery](https://console.cloud.google.com/bigquery). 2. Select a dataset from `Resources`. 3. Click `SHARING` near the right side of the window and select `Permissions`. 4. Validate that none of the attached roles contain `allUsers` or `allAuthenticatedUsers`. **From Google Cloud CLI** List the name of all datasets. bq ls Retrieve each dataset details using the following command: bq show PROJECT_ID:DATASET_NAME Ensure that `allUsers` and `allAuthenticatedUsers` have not been granted access to the dataset.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/bigquery/docs/dataset-access-controls", + "DefaultValue": "By default, BigQuery datasets are not publicly accessible.", + "SubSection": null + } + ] + }, + { + "Id": "7.2", + "Description": "Ensure That All BigQuery Tables Are Encrypted With Customer-Managed Encryption Key (CMEK)", + "Checks": [ + "bigquery_table_cmk_encryption" + ], + "Attributes": [ + { + "Section": "7 BigQuery", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "BigQuery by default encrypts the data as rest by employing `Envelope Encryption` using Google managed cryptographic keys. The data is encrypted using the `data encryption keys` and data encryption keys themselves are further encrypted using `key encryption keys`. This is seamless and do not require any additional input from the user. However, if you want to have greater control, Customer-managed encryption keys (CMEK) can be used as encryption key management solution for BigQuery Data Sets. If CMEK is used, the CMEK is used to encrypt the data encryption keys instead of using google-managed encryption keys.", + "RationaleStatement": "BigQuery by default encrypts the data as rest by employing `Envelope Encryption` using Google managed cryptographic keys. This is seamless and does not require any additional input from the user. For greater control over the encryption, customer-managed encryption keys (CMEK) can be used as encryption key management solution for BigQuery tables. The CMEK is used to encrypt the data encryption keys instead of using google-managed encryption keys. BigQuery stores the table and CMEK association and the encryption/decryption is done automatically. Applying the Default Customer-managed keys on BigQuery data sets ensures that all the new tables created in the future will be encrypted using CMEK but existing tables need to be updated to use CMEK individually. Note: Google does not store your keys on its servers and cannot access your protected data unless you provide the key. This also means that if you forget or lose your key, there is no way for Google to recover the key or to recover any data encrypted with the lost key. ", + "ImpactStatement": "Using Customer-managed encryption keys (CMEK) will incur additional labor-hour investment to create, protect, and manage the keys.", + "RemediationProcedure": "**From Google Cloud CLI** Use the following command to copy the data. The source and the destination needs to be same in case copying to the original table. bq cp --destination_kms_key source_dataset.source_table destination_dataset.destination_table ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Analytics` 2. Go to `BigQuery` 3. Under `SQL Workspace`, select the project 4. Select Data Set, select the table 5. Go to `Details` tab 6. Under `Table info`, verify `Customer-managed key` is present. 7. Repeat for each table in all data sets for all projects. **From Google Cloud CLI** List all dataset names bq ls Use the following command to view the table details. Verify the `kmsKeyName` is present. bq show ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/bigquery/docs/customer-managed-encryption", + "DefaultValue": "Google Managed keys are used as `key encryption keys`.", + "SubSection": null + } + ] + }, + { + "Id": "7.3", + "Description": "Ensure That a Default Customer-Managed Encryption Key (CMEK) Is Specified for All BigQuery Data Sets", + "Checks": [ + "bigquery_dataset_cmk_encryption" + ], + "Attributes": [ + { + "Section": "7 BigQuery", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "BigQuery by default encrypts the data as rest by employing `Envelope Encryption` using Google managed cryptographic keys. The data is encrypted using the `data encryption keys` and data encryption keys themselves are further encrypted using `key encryption keys`. This is seamless and do not require any additional input from the user. However, if you want to have greater control, Customer-managed encryption keys (CMEK) can be used as encryption key management solution for BigQuery Data Sets.", + "RationaleStatement": "BigQuery by default encrypts the data as rest by employing `Envelope Encryption` using Google managed cryptographic keys. This is seamless and does not require any additional input from the user. For greater control over the encryption, customer-managed encryption keys (CMEK) can be used as encryption key management solution for BigQuery Data Sets. Setting a Default Customer-managed encryption key (CMEK) for a data set ensure any tables created in future will use the specified CMEK if none other is provided. Note: Google does not store your keys on its servers and cannot access your protected data unless you provide the key. This also means that if you forget or lose your key, there is no way for Google to recover the key or to recover any data encrypted with the lost key. ", + "ImpactStatement": "Using Customer-managed encryption keys (CMEK) will incur additional labor-hour investment to create, protect, and manage the keys.", + "RemediationProcedure": "**From Google Cloud CLI** The default CMEK for existing data sets can be updated by specifying the default key in the `EncryptionConfiguration.kmsKeyName` field when calling the `datasets.insert` or `datasets.patch` methods", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Analytics` 2. Go to `BigQuery` 3. Under `Analysis` click on `SQL Workspaces`, select the project 4. Select Data Set 5. Ensure `Customer-managed key` is present under `Dataset info` section. 6. Repeat for each data set in all projects. **From Google Cloud CLI** List all dataset names bq ls Use the following command to view each dataset details. bq show Verify the `kmsKeyName` is present.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/bigquery/docs/customer-managed-encryption", + "DefaultValue": "Google Managed keys are used as `key encryption keys`.", + "SubSection": null + } + ] + }, + { + "Id": "7.4", + "Description": "Ensure all data in BigQuery has been classified", + "Checks": [], + "Attributes": [ + { + "Section": "7 BigQuery", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "BigQuery tables can contain sensitive data that for security purposes should be discovered, monitored, classified, and protected. Google Cloud's Sensitive Data Protection tools can automatically provide data classification of all BigQuery data across an organization.", + "RationaleStatement": "Using a cloud service or 3rd party software to continuously monitor and automate the process of data discovery and classification for BigQuery tables is an important part of protecting the data. Sensitive Data Protection is a fully managed data protection and data privacy platform that uses machine learning and pattern matching to discover and classify sensitive data in Google Cloud.", + "ImpactStatement": "There is a cost associated with using Sensitive Data Protection. There is also typically a cost associated with 3rd party tools that perform similar processes and protection.", + "RemediationProcedure": "**Enable profiling:** 1. Go to Cloud DLP by visiting https://console.cloud.google.com/dlp/landing/dataProfiles/configurations 1. Click Create Configuration 1. For projects follow https://cloud.google.com/dlp/docs/profile-project. For organizations or folders follow https://cloud.google.com/dlp/docs/profile-org-folder **Review findings:** - Columns or tables with high data risk have evidence of sensitive information without additional protections. To lower the data risk score, consider doing the following: - For columns containing sensitive data, apply a BigQuery policy tag to restrict access to accounts with specific access rights. - De-identify the raw sensitive data using de-identification techniques like masking and tokenization. **Incorporate findings into your security and governance operations:** - Enable sending findings into your security and posture services. You can publish data profiles to Security Command Center and Chronicle. - Automate remediation or enable alerting of new or changed data risk with Pub/Sub.", + "AuditProcedure": "1. Go to Cloud DLP by visiting https://console.cloud.google.com/dlp/landing/dataProfiles/configurations. 2. Verify there is a discovery scan configuration either for the organization or project.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/dlp/docs/data-profiles:https://cloud.google.com/dlp/docs/analyze-data-profiles:https://cloud.google.com/dlp/docs/data-profiles-remediation:https://cloud.google.com/dlp/docs/send-profiles-to-scc:https://cloud.google.com/dlp/docs/profile-org-folder#chronicle:https://cloud.google.com/dlp/docs/profile-org-folder#publish-pubsub", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "8.1", + "Description": "Ensure that Dataproc Cluster is encrypted using Customer-Managed Encryption Key", + "Checks": [ + "dataproc_encrypted_with_cmks_disabled" + ], + "Attributes": [ + { + "Section": "8 Dataproc", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "When you use Dataproc, cluster and job data is stored on Persistent Disks (PDs) associated with the Compute Engine VMs in your cluster and in a Cloud Storage staging bucket. This PD and bucket data is encrypted using a Google-generated data encryption key (DEK) and key encryption key (KEK). The CMEK feature allows you to create, use, and revoke the key encryption key (KEK). Google still controls the data encryption key (DEK).", + "RationaleStatement": "Cloud services offer the ability to protect data related to those services using encryption keys managed by the customer within Cloud KMS. These encryption keys are called customer-managed encryption keys (CMEK). When you protect data in Google Cloud services with CMEK, the CMEK key is within your control.", + "ImpactStatement": "Using Customer Managed Keys involves additional overhead in maintenance by administrators.", + "RemediationProcedure": "**From Google Cloud Console** 1. Login to the GCP Console and navigate to the Dataproc Cluster page by visiting https://console.cloud.google.com/dataproc/clusters. 1. Select the project from the projects dropdown list. 1. On the `Dataproc Cluster` page, click on the `Create Cluster` to create a new cluster with Customer managed encryption keys. 1. On `Create a cluster` page, perform below steps: - Inside `Set up cluster` section perform below steps: -In the `Name` textbox, provide a name for your cluster. - From `Location` select the location in which you want to deploy a cluster. - Configure other configurations as per your requirements. - Inside `Configure Nodes` and `Customize cluster` section configure the settings as per your requirements. - Inside `Manage security` section, perform below steps: - From `Encryption`, select `Customer-managed key`. - Select a customer-managed key from dropdown list. - Ensure that the selected KMS Key have Cloud KMS CryptoKey Encrypter/Decrypter role assign to Dataproc Cluster service account (serviceAccount:service-@compute-system.iam.gserviceaccount.com). - Click on `Create` to create a cluster. - Once the cluster is created migrate all your workloads from the older cluster to the new cluster and delete the old cluster by performing the below steps: - On the `Clusters` page, select the old cluster and click on `Delete cluster`. - On the `Confirm deletion` window, click on `Confirm` to delete the cluster. - Repeat step above for other Dataproc clusters available in the selected project. - Change the project from the project dropdown list and repeat the remediation procedure for other Dataproc clusters available in other projects. **From Google Cloud CLI** Before creating cluster ensure that the selected KMS Key have Cloud KMS CryptoKey Encrypter/Decrypter role assign to Dataproc Cluster service account (serviceAccount:service-@compute-system.iam.gserviceaccount.com). Run clusters create command to create new cluster with customer-managed key: gcloud dataproc clusters create --region=us-central1 --gce-pd-kms-key= The above command will create a new cluster in the selected region. Once the cluster is created migrate all your workloads from the older cluster to the new cluster and Run clusters delete command to delete cluster: gcloud dataproc clusters delete --region=us-central1 Repeat step no. 1 to create a new Dataproc cluster. Change the project by running the below command and repeat the remediation procedure for other projects: gcloud config set project ", + "AuditProcedure": "**From Google Cloud Console** 1. Login to the GCP Console and navigate to the Dataproc Cluster page by visiting https://console.cloud.google.com/dataproc/clusters. 1. Select the project from the project dropdown list. 1. On the `Dataproc Clusters` page, select the cluster and click on the Name attribute value that you want to examine. 1. On the `details` page, select the `Configurations` tab. 1. On the `Configurations` tab, check the `Encryption type` configuration attribute value. If the value is set to `Google-managed key`, then Dataproc Cluster is not encrypted with Customer managed encryption keys. Repeat step no. 3 - 5 for other Dataproc Clusters available in the selected project. 6. Change the project from the project dropdown list and repeat the audit procedure for other projects. **From Google Cloud CLI** 1. Run clusters list command to list all the Dataproc Clusters available in the region: gcloud dataproc clusters list --region='us-central1' 2. Run clusters describe command to get the key details of the selected cluster: gcloud dataproc clusters describe --region=us-central1 --flatten=config.encryptionConfig.gcePdKmsKeyName 3. If the above command output return null, then the selected cluster is not encrypted with Customer managed encryption keys. 4. Repeat step no. 2 and 3 for other Dataproc Clusters available in the selected region. Change the region by updating --region and repeat step no. 2 for other clusters available in the project. Change the project by running the below command and repeat the audit procedure for other Dataproc clusters available in other projects: gcloud config set project ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/docs/security/encryption/default-encryption", + "DefaultValue": "", + "SubSection": null + } + ] + } + ] +} diff --git a/prowler/compliance/github/cis_1.2.0_github.json b/prowler/compliance/github/cis_1.2.0_github.json new file mode 100644 index 0000000000..840b5aa3d8 --- /dev/null +++ b/prowler/compliance/github/cis_1.2.0_github.json @@ -0,0 +1,2574 @@ +{ + "Framework": "CIS", + "Name": "CIS GitHub Benchmark v1.2.0", + "Version": "1.2.0", + "Provider": "GitHub", + "Description": "This document provides prescriptive guidance for establishing a secure configuration posture for securing GitHub.", + "Requirements": [ + { + "Id": "1.1.1", + "Description": "Manage all code projects in a version control platform.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Manage all code projects in a version control platform.", + "RationaleStatement": "Version control platforms keep track of every modification to code. They represent the cornerstone of code security, as well as allowing for better code collaboration within engineering teams. With granular access management, change tracking, and key signing of code edits, version control platforms are the first step in securing the software supply chain.", + "ImpactStatement": "", + "RemediationProcedure": "Upload existing code projects to a dedicated Github organization and repositories and create an identity for each active team member who might contribute or need access to it.", + "AuditProcedure": "Ensure that all code activity is managed through Github repository for every micro-service or application developed by an organization.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.2", + "Description": "Use a task management system to trace any code back to its associated task.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Use a task management system to trace any code back to its associated task.", + "RationaleStatement": "The ability to trace each piece of code back to its associated task simplifies the Agile and DevOps process by enabling transparency of any code changes. This allows faster remediation of bugs and security issues, while also making it harder to push unauthorized code changes to sensitive projects. Additionally, using a task management system simplifies achieving compliance, as it is easier to track each regulation.", + "ImpactStatement": "", + "RemediationProcedure": "Use a task management system to manage tasks as the starting point for each code change. Whether it is a new feature, bug fix, or security fix - all should originate from a dedicated task (ticket) in your organization's task management system. These tasks should also be linked to the code changes themselves in a way that is easy to follow: from code to task, and from task back to code.", + "AuditProcedure": "Ensure every code change can be traced back to its origin task in a task management system.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.3", + "Description": "Ensure that every code change is reviewed and approved by two authorized contributors who are both strongly authenticated - using Multi-Factor Authentication (MFA), from the team relevant to the code change.", + "Checks": [ + "repository_default_branch_requires_multiple_approvals" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that every code change is reviewed and approved by two authorized contributors who are both strongly authenticated - using Multi-Factor Authentication (MFA), from the team relevant to the code change.", + "RationaleStatement": "To prevent malicious or unauthorized code changes, the first layer of protection is the process of code review. This process involves engineer teammates reviewing each other's code for errors, optimizations, and general knowledge-sharing. With proper peer reviews in place, an organization can detect unwanted code changes very early in the process of release. In order to help facilitate code review, companies should employ automation to verify that every code change has been reviewed and approved by at least two team members before it is pushed into the code base. These team members should be from the team that is related to the code change, so it will be a meaningful review.", + "ImpactStatement": "To enforce a code review requirement, verification for a minimum of two reviewers must be put into place. This will ensure new code will not be able to be pushed to the code base before it has received two independent approvals.", + "RemediationProcedure": "For every code repository in use, perform the next steps to require two approvals from the specific code repository team in order to push new code to the code base:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you added the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Check **Require a pull request before merging** and **Require approvals**, and set **Required number of approvals before merging** to 2.\n5. Click **Create** or **Save changes**.", + "AuditProcedure": "For every code repository in use, perform the next steps to verify that two approvals from the specific code repository team are required to push new code to the code base:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Require a pull request before merging** and **Require approvals** are checked, and verify that **Required number of approvals before merging** is set to 2.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "0" + } + ] + }, + { + "Id": "1.1.4", + "Description": "Ensure that when a proposed code change is updated, previous approvals are declined, and new approvals are required.", + "Checks": [ + "repository_default_branch_dismisses_stale_reviews" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that when a proposed code change is updated, previous approvals are declined, and new approvals are required.", + "RationaleStatement": "An approval process is necessary when code changes are suggested. Through this approval process, however, changes can still be made to the original proposal even after some approvals have already been given. This means malicious code can find its way into the code base even if the organization has enforced a review policy. To ensure this is not possible, outdated approvals must be declined when changes to the suggestion are introduced.", + "ImpactStatement": "If new code changes are pushed to a specific proposal, all previously accepted code change proposals must be declined.", + "RemediationProcedure": "For each code repository in use, perform the next steps to enforce dismissal of given approvals to code change suggestions if those suggestions were updated:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you added the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Select **Require pull request reviews before merging** and then **Dismiss stale pull request approvals when new commits are pushed**.\n5. Click **Create** or **Save changes**.", + "AuditProcedure": "For each code repository in use, perform the next steps to verify that each updated code suggestion declines the previously received approvals:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Require a pull request before merging** is checked, and verify that **Dismiss stale pull request approvals when new commits are pushed** is checked.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.5", + "Description": "Only trusted users should be allowed to dismiss code change reviews.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Only trusted users should be allowed to dismiss code change reviews.", + "RationaleStatement": "Dismissing a code change review permits users to merge new suggested code changes without going through the standard process of approvals. Controlling who can perform this action will prevent malicious actors from simply dismissing the required reviews to code changes and merging malicious or dysfunctional code into the code base.", + "ImpactStatement": "In cases where a code change proposal has been updated since it was last reviewed and the person who reviewed it isn't available for approval, a general collaborator would not be able to merge their code changes until a user with \"dismiss review\" abilities could dismiss the open review.\n\nUsers who are not allowed to dismiss code change reviews will not be permitted to do so, and thus are unable to waive the standard flow of approvals.", + "RemediationProcedure": "For each code repository in use, perform the next steps to restrict dismissal of code changes reviews unless it is necessary:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you added the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n4. Select **Require pull request reviews before merging** and **Restrict who can dismiss pull request reviews**.\n5. Do not add any user or team unless it is obligatory. If it is obligatory, carefully select the users or teams whom you trust with the ability to dismiss code change reviews.\n6. Click **Create** or **Save changes**.", + "AuditProcedure": "For each code repository in use, perform the next steps to verify that only trusted users are allowed to dismiss code change reviews: \n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n4. Verify that **Require a pull request before merging** and **Restrict who can dismiss pull request reviews** is checked.\n5. Verify that no users and teams are specified except for organization and repository admins. If it is obligatory, verify that the users or teams specified were carefully selected to be trusted with the ability to dismiss code change reviews.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, all users who have write access to the code repository are able to dismiss code change reviews." + } + ] + }, + { + "Id": "1.1.6", + "Description": "Code owners are trusted users that are responsible for reviewing and managing an important piece of code or configuration. An organization is advised to set code owners for every extremely sensitive code or configuration.", + "Checks": [ + "repository_has_codeowners_file" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Code owners are trusted users that are responsible for reviewing and managing an important piece of code or configuration. An organization is advised to set code owners for every extremely sensitive code or configuration.", + "RationaleStatement": "Configuring code owners protects data by verifying that trusted users will notice and review every edit, thus preventing unwanted or malicious changes from potentially compromising sensitive code or configurations.", + "ImpactStatement": "Code owner users will receive notifications for every change that occurs to the code and subsequently added as reviewers of pull requests automatically.", + "RemediationProcedure": "In every code repository create a CODEOWNERS file in the root, docs/, or .github/ directory of the repository.\nIn the file, specify codeowners for the .github/workflows/ directory atleast. Specify organization members you trust. For example:\n```\n.github/workflows/ @user1 @user2\n```", + "AuditProcedure": "In every code repository, verify that a file named CODEOWNERS exists in the root, docs/, or .github/ directory of the repository.\nIn the CODEOWNERS file, verify that the users specified are users you trust.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners", + "DefaultValue": "None" + } + ] + }, + { + "Id": "1.1.7", + "Description": "Ensure trusted code owners are required to review and approve any code change proposal made to their respective owned areas in the code base.", + "Checks": [ + "repository_default_branch_requires_codeowners_review" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure trusted code owners are required to review and approve any code change proposal made to their respective owned areas in the code base.", + "RationaleStatement": "Configuring code owners ensures that no code, especially code which could prove malicious, will slip into the source code or configuration files of a repository. This allows an organization to mark areas in the code base that are especially sensitive or more prone to an attack. It can also enforce review by specific individuals who are designated as owners to those areas so that they may filter out unauthorized or unwanted changes beforehand.", + "ImpactStatement": "If an organization enforces code owner-based reviews, some code change proposals would not be able to be merged to the codebase before specific, trusted individuals approve them.", + "RemediationProcedure": "For every code repository in use, perform the following steps to require code owners' approvals for each change proposal related to code they own:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Select **Require pull request reviews before merging** and **Require review from Code Owners**.\n7. Click **Create** or **Save changes**.", + "AuditProcedure": "For every code repository in use, perform the following steps to verify that code owners are required to review all code change proposals relevant to areas they own before code merge:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n4. Ensure that **Require a pull request before merging** and **Require review from Code Owners** are checked.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "Code owners are not required to review changes by default." + } + ] + }, + { + "Id": "1.1.8", + "Description": "Keep track of code branches that are inactive for a lengthy period of time and periodically remove them.", + "Checks": [ + "repository_inactive_not_archived", + "repository_branch_delete_on_merge_enabled" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Keep track of code branches that are inactive for a lengthy period of time and periodically remove them.", + "RationaleStatement": "Git branches that have been inactive (i.e., no new changes introduced) for a long period of time are enlarging the surface of attack for malicious code injection, sensitive data leaks, and CI pipeline exploitation. They potentially contain outdated dependencies which may leave them highly vulnerable. They are more likely to be improperly managed, and could possibly be accessed by a large number of members of the organization.", + "ImpactStatement": "Removing inactive Git branches means that any code changes they contain would be removed along with them, thus work done in the past might not be accessible after auditing for inactivity.", + "RemediationProcedure": "For each code repository in use, review existing Git branches and remove those which have not been active for a period of time by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Above the list of files, click **Branches**.\n3. Use the navigation at the top of the page to view the Stale branches. The Stale view shows all branches that no one has committed to in the last three months, ordered by the branches with the oldest commits first.\n4. For each branch listed, either delete it by clicking the trash bin icon, or find the valid reason it still exists.\n\nYou can perform the next steps to prevent pull request branches from becoming stale branches:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click Settings.\n3. Under \"Pull Requests\", select **Automatically delete head branches**.", + "AuditProcedure": "For each code repository in use, verify that all existing Git branches are active or have yet to be checked for inactivity by performing the next steps:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Above the list of files, click **Branches**.\n3. Use the navigation at the top of the page to view the Stale branches. The Stale view shows all branches that no one has committed to in the last three months, ordered by the branches with the oldest commits first.\n4. If the list is empty, you are compliant. If the list is not empty, but there is a valid reason the branches listed are not deleted, you are compliant.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, newly opened Git branches would never be removed, regardless of activity or inactivity." + } + ] + }, + { + "Id": "1.1.9", + "Description": "Before a code change request can be merged to the code base, all predefined checks must successfully pass.", + "Checks": [ + "repository_default_branch_status_checks_required" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Before a code change request can be merged to the code base, all predefined checks must successfully pass.", + "RationaleStatement": "On top of manual reviews of code changes, a code protect should contain a set of prescriptive checks which validate each change. Organizations should enforce those status checks so that changes can only be introduced if all checks have successfully passed. This set of checks should serve as the absolute quality, stability, and security conditions which must be met in order to merge new code to a project.", + "ImpactStatement": "Code changes in which all checks do not pass successfully would not be able to be pushed into the code base of the specific code repository.", + "RemediationProcedure": "For each code repository in use, require all status checks to pass before permitting a merge of new code by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", check if there is a rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Select **Require status checks to pass before merging**.\n7. Click **Create** or **Save changes**.", + "AuditProcedure": "For each code repository in use, verify that status checks are required to pass before allowing any code change proposal merge by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Require status checks to pass before merging** is checked.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, no checks are defined per project, and thus no enforcement of checks is made." + } + ] + }, + { + "Id": "1.1.10", + "Description": "Organizations should make sure each suggested code change is in full sync with the existing state of its origin code repository before allowing merging.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Organizations should make sure each suggested code change is in full sync with the existing state of its origin code repository before allowing merging.", + "RationaleStatement": "Git branches can easily become outdated since the origin code repository is constantly being edited. This means engineers working on separate code branches can accidentally include outdated code with potential security issues which might have already been fixed, overriding the potential solutions for those security issues when merging their own changes.", + "ImpactStatement": "If enforced, outdated branches would not be able to be merged into their origin repository without first being updated to contain any recent changes.", + "RemediationProcedure": "For each code repository in use, enforce a policy to only allow merging open branches if they are current with the latest change from their original repository by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Select **Require status checks to pass before merging** and **Require branches to be up to date before merging**.\n7. Click **Create** or **Save changes**.", + "AuditProcedure": "For each code repository in use, verify that open branches must be updated before merging is permitted by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Require status checks to pass before merging** and **Require branches to be up to date before merging** are checked.", + "AdditionalInformation": "", + "References": "https://github.blog/changelog/2022-02-03-more-ways-to-keep-your-pull-request-branch-up-to-date/", + "DefaultValue": "By default, there is no requirement to update a branch before merging it." + } + ] + }, + { + "Id": "1.1.11", + "Description": "Organizations should enforce a \"no open comments\" policy before allowing code change merging.", + "Checks": [ + "repository_default_branch_requires_conversation_resolution" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Organizations should enforce a \"no open comments\" policy before allowing code change merging.", + "RationaleStatement": "In an open code change proposal, reviewers can leave comments containing their questions and suggestions. These comments can also include potential bugs and security issues. Requiring all comments on a code change proposal to be resolved before it can be merged ensures that every concern is properly addressed or acknowledged before the new code changes are introduced to the code base.", + "ImpactStatement": "Code change proposals containing open comments would not be able to be merged into the code base.", + "RemediationProcedure": "For each code repository in use, require open comments to be resolved before the relevant code change can be merged by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Select **Require conversation resolution before merging**.\n7. Click **Create** or **Save changes**.", + "AuditProcedure": "For every code repository in use, verify that each merged code change does not contain open, unattended comments by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Require conversation resolution before merging** is checked.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, code changes with open comments on them are able to be merged into the code \nbase." + } + ] + }, + { + "Id": "1.1.12", + "Description": "Ensure every commit in a pull request is signed and verified before merging.", + "Checks": [ + "repository_default_branch_requires_signed_commits" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure every commit in a pull request is signed and verified before merging.", + "RationaleStatement": "Signing commits, or requiring to sign commits, gives other users confidence about the origin of a specific code change. It ensures that the author of the change is not hidden and is verified by the version control system, thus the change comes from a trusted source.", + "ImpactStatement": "Pull requests with unsigned commits cannot be merged.", + "RemediationProcedure": "For each repository in use, enforce the branch protection rule of requiring signed commits, and make sure only signed commits are capable of merging by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Select **Require signed commits**.\n7. Click **Create** or **Save changes**.", + "AuditProcedure": "Ensure only signed commits can be merged for every branch, especially the main branch, via branch protection rules by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Require signed commits** is checked.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.13", + "Description": "Linear history is the name for Git history where all commits are listed in chronological order, one after another. Such history exists if a pull request is merged either by rebase merge (re-order the commits history) or squash merge (squashes all commits to one). Ensure that linear history is required by requiring the use of rebase or squash merge when merging a pull request.", + "Checks": [ + "repository_default_branch_requires_linear_history" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Linear history is the name for Git history where all commits are listed in chronological order, one after another. Such history exists if a pull request is merged either by rebase merge (re-order the commits history) or squash merge (squashes all commits to one). Ensure that linear history is required by requiring the use of rebase or squash merge when merging a pull request.", + "RationaleStatement": "Enforcing linear history produces a clear record of activity, and as such it offers specific advantages: it is easier to follow, easier to revert a change, and bugs can be found more easily.", + "ImpactStatement": "Pull request cannot be merged except squash or rebase merge.", + "RemediationProcedure": "For every code repository in use, perform the following steps to require linear history and/or allow only rebase merge and squash merge:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Select **Require linear history**.\n7. Click **Create** or **Save changes**.", + "AuditProcedure": "For every code repository in use, perform the following steps to verify that linear history is required and/or that only squash merge and rebase merge are allowed:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Require linear history** is checked.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.14", + "Description": "Ensure administrators are subject to branch protection rules.", + "Checks": [ + "repository_default_branch_protection_applies_to_admins" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure administrators are subject to branch protection rules.", + "RationaleStatement": "Administrators by default are excluded from any branch protection rules. This means these privileged users (both on the repository and organization levels) are not subject to protections meant to prevent untrusted code insertion, including malicious code. This is extremely important since administrator accounts are often targeted for account hijacking due to their privileged role.", + "ImpactStatement": "Administrator users won't be able to push code directly to the protected branch without being compliant with listed branch protection rules.", + "RemediationProcedure": "For every code repository in use, enforce branch protection rules on administrators as well, by performing the following:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Select **Do not allow bypassing the above settings**.\n7. Click **Create** or **Save changes**.", + "AuditProcedure": "For every code repository in use, validate branch protection rules also apply to administrator accounts by performing the next steps:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Do not allow bypassing the above settings** is checked.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/managing-a-branch-protection-rule", + "DefaultValue": "Administrator accounts are not subject to branch protection rules by default." + } + ] + }, + { + "Id": "1.1.15", + "Description": "Ensure that only trusted users can push or merge new code to protected branches.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that only trusted users can push or merge new code to protected branches.", + "RationaleStatement": "Requiring that only trusted users may push or merge new changes reduces the risk of unverified code, especially malicious code, to a protected branch by reducing the number of trusted users who are capable of doing such.", + "ImpactStatement": "Only administrators and trusted users can push or merge to the protected branch.", + "RemediationProcedure": "For every code repository in use, allow only trusted and responsible users to push or merge new code by performing the following:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4.Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Select **Restrict who can push to matching branches** and choose trusted and responsible users and teams who will have the permission to do so. \n7. Click **Create** or **Save changes**.", + "AuditProcedure": "For every code repository in use, ensure only trusted and responsible users can push or merge new code by performing the following:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Restrict who can push to matching branches** is checked and that only trusted and responsible users and teams are selected.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/managing-a-branch-protection-rule", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.16", + "Description": "The \"Force Push\" option allows users with \"Push\" permissions to force their changes directly to the branch without a pull request, and thus should be disabled.", + "Checks": [ + "repository_default_branch_disallows_force_push" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "The \"Force Push\" option allows users with \"Push\" permissions to force their changes directly to the branch without a pull request, and thus should be disabled.", + "RationaleStatement": "The \"Force Push\" option allows users to override the existing code with their own code. This can lead to both intentional and unintentional data loss, as well as data infection with malicious code. Disabling the \"Force Push\" option prohibits users from forcing their changes to the master branch, which ultimately prevents malicious code from entering source code.", + "ImpactStatement": "Users cannot force push to protected branches.", + "RemediationProcedure": "For each repository in use, block the option to \"Force Push\" code by performing the following:\n\nEnsure that Trunk / Long-standing branches are protected by Policies and submitted to PR while applying the 4-eyes approach.\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Uncheck **Allow force pushes**.\n7. Click **Create** or **Save changes**.", + "AuditProcedure": "For every code repository in use, validate that no one can force push code by performing the following:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Allow force pushes** is not checked.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/managing-a-branch-protection-rule", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.17", + "Description": "Ensure that users with only push access are incapable of deleting a protected branch.", + "Checks": [ + "repository_default_branch_deletion_disabled" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that users with only push access are incapable of deleting a protected branch.", + "RationaleStatement": "When enabling deletion of a protected branch, any user with at least push access to the repository can delete a branch. This can be potentially dangerous, as a simple human mistake or a hacked account can lead to data loss if a branch is deleted. It is therefore crucial to prevent such incidents by denying protected branch deletion.", + "ImpactStatement": "Protected branches cannot be deleted.", + "RemediationProcedure": "For each repository that is being used, block the option to delete protected branches by performing the following:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Uncheck **Allow deletions**.\n7. Click **Create** or **Save changes**.", + "AuditProcedure": "For each repository that is being used, verify that protected branches cannot be deleted by performing the following:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Allow deletions** is not checked.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/managing-a-branch-protection-rule", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.18", + "Description": "Ensure that every pull request is required to be scanned for risks.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that every pull request is required to be scanned for risks.", + "RationaleStatement": "Scanning pull requests to detect risks allows for early detection of vulnerable code and/or dependencies and helps mitigate potentially malicious code.", + "ImpactStatement": "", + "RemediationProcedure": "For every repository in use, enforce risk scanning on every pull request by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Actions**.\n3. If the repository already has at least one workflow set up and running, click **New workflow** and go to step 5. If there are currently no workflows configured for the repository, go to the next step.\n4. Scroll down to the \"Security\" category and click **Configure** under the workflow you want to configure or click **View all** to see all available security workflows.\n5. On the right pane of the workflow page, click **Documentation** and follow the on-screen instructions to tailor the workflow to your needs.", + "AuditProcedure": "For each repository in use, ensure that every pull request must be scanned for risks by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Actions**.\n3. Ensure that at least one workflow is configured to run like that: \n```\non:\n push:\n branches: [ \"main\" ]\n pull_request:\n branches: [ \"main\" ]\n```\nand that it has a step that runs code scanning on the code.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.19", + "Description": "Ensure that changes in the branch protection rules are audited.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that changes in the branch protection rules are audited.", + "RationaleStatement": "Branch protection rules should be configured on every repository. The only users who may change such rules are administrators. In a case of an attack on an administrator account or of human error on the part of an administrator, protection rules could be disabled, and thus decrease source code confidentiality as a result. It is important to track and audit such changes to prevent potential incidents as soon as possible.", + "ImpactStatement": "", + "RemediationProcedure": "Use the audit log to audit changes in branch protection rules by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Archives\" section of the sidebar, click **Logs**, then click **Audit log**.\n4. Use the action qualifier in your query and look for **protected_branch** category. Ensure every action is reasonable and secure and investigate if not.", + "AuditProcedure": "Ensure changes in branch protection rules are audited by performing the following regularly:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Archives\" section of the sidebar, click **Logs**, then click **Audit log**.\n4. Use the action qualifier in your query and look for **protected_branch** category. Ensure every action is reasonable and secure and is investigated if not.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/about-protected-branches", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.20", + "Description": "Enforce branch protection on the default and main branch.", + "Checks": [ + "repository_default_branch_protection_enabled" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Enforce branch protection on the default and main branch.", + "RationaleStatement": "The default or main branch of repositories is considered very important, as it is eventually gets deployed to the production. Therefore it needs protection. By enforcing branch protection rules on this branch, it is secured from unwanted or unauthorized changes. It can also be protected from untested and unreviewed changes and more.", + "ImpactStatement": "", + "RemediationProcedure": "Perform the following to enforce branch protection on the main branch:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", click **Add rule**.\n5. Under \"Branch name pattern\", type the branch name or pattern you want to protect. Ensure it applies to the main branch name.\n6. Configure policies you want.\n7. Click Create.", + "AuditProcedure": "Perform the following to ensure branch protection is enforced on the main branch:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Under **Branch protection rules**, verify that there is a rule applied to the \"main\" or default branch.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.2.1", + "Description": "A SECURITY.md file is a security policy file that offers instruction on reporting security vulnerabilities in a project. When someone creates an issue within a specific project, a link to the SECURITY.md file will subsequently be shown.", + "Checks": [ + "repository_public_has_securitymd_file" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.2 Repository Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "A SECURITY.md file is a security policy file that offers instruction on reporting security vulnerabilities in a project. When someone creates an issue within a specific project, a link to the SECURITY.md file will subsequently be shown.", + "RationaleStatement": "A SECURITY.md file provides users with crucial security information. It can also serve an important role in project maintenance, encouraging users to think ahead about how to properly handle potential security issues, updates, and general security practices.", + "ImpactStatement": "", + "RemediationProcedure": "Enforce that each public repository has a SECURITY.md file by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under the repository name, click **Security**.\n3. In the left sidebar, click **Security policy**.\n4. Click **Start setup**.\n5. In the new SECURITY.md file, add information about supported versions of your project and how to report a vulnerability.\n6. At the bottom of the page, type a commit message.\n7. Below the commit message fields, choose to create a new branch for your commit and then create a pull request.\n8. Click **Propose file change**.", + "AuditProcedure": "Verify that each public repository has a SECURITY.md file by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under the repository name, click **Security**.\n3. Verify that **Security policy** is enabled.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.2.2", + "Description": "Limit the ability to create repositories to trusted users and teams.", + "Checks": [ + "organization_repository_creation_limited" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.2 Repository Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Limit the ability to create repositories to trusted users and teams.", + "RationaleStatement": "Restricting repository creation to trusted users and teams is recommended in order to keep the organization properly structured, track fewer items, prevent impersonation, and to not overload the version-control system. It will allow administrators easier source code tracking and management capabilities, as they will have fewer repositories to track. The process of detecting potential attacks also becomes far more straightforward, as well, since the easier it is to track the source code, the easier it is to detect malicious acts within it. Additionally, the possibility of a member creating a public repository and sharing the organization's data externally is significantly decreased.", + "ImpactStatement": "Specific users will not be permitted to create repositories.", + "RemediationProcedure": "Restrict repository creation to trusted users and teams only by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Access\" section of the sidebar, click **Member privileges**.\n4. Under \"Repository creation\", unselect both options - **Public** and **Private**.\n5. Click **Save**.", + "AuditProcedure": "Verify that only trusted users and teams can create repositories by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Access\" section of the sidebar, click **Member privileges**.\n4. Under \"Repository creation\", ensure that **Public** and **Private** are not checked. This means only owners are able to create repositories.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.2.3", + "Description": "Ensure only a limited number of trusted users can delete repositories.", + "Checks": [ + "organization_repository_deletion_limited" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.2 Repository Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure only a limited number of trusted users can delete repositories.", + "RationaleStatement": "Restricting the ability to delete repositories protects the organization from intentional and unintentional data loss. This ensures that users cannot delete repositories or cause other potential damage — whether by accident or due to their account being hacked — unless they have the correct privileges.", + "ImpactStatement": "Certain users will not be permitted to delete repositories.", + "RemediationProcedure": "Enforce repository deletion by a few trusted and responsible users only by performing either of the following steps:\n\nIf Your organizations > Settings > Access > Member privileges > Allow members to delete or transfer repositories for this organization is selected, allow only trusted members to have admin privileges:\n1. In every repository, on GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings** and then in the \"Access\" section of the sidebar, click **Collaborators & teams**.\n3. Under \"Manage access\" use the dropdown **Role** menu to filter and search for admin members. Change their role by clicking the **Role** dropdown next to the username until there are only two trusted and qualified users with admin privileges. \n\nIf it is not selected, allow only trusted users to become an organization owner:\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Click the name of your organization and under your organization name, click **People**.\n3. You will see a list of the people in your organization. Click Role and select Owners. Check every member you want to change permissions to. Above the list of members, use the drop-down menu and click Change role and choose Member > change role. Do that until there are only a few trusted and qualified users with organization owner privileges. \n\nIn any case, only members with administrator or organization owner privileges can delete repositories, regardless of your setting.", + "AuditProcedure": "Verify that only a limited number of trusted users can delete repositories by performing either of the following steps:\n\nIf Your organizations > Settings > Access > Member privileges > Allow members to delete or transfer repositories for this organization is selected, verify that every admin member is trusted by you:\n1. In every repository, on GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings** and then in the \"Access\" section of the sidebar, click **Collaborators & teams**.\n3. Under \"Manage access\" use the dropdown **Role** menu to filter and search for admin members. Verify that there are only two of them and that they are trusted and qualified. \n\nIf it is not selected, verify that every organization owner is trusted by you:\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Click the name of your organization and under your organization name, click **People**.\n3. You will see a list of the people in your organization. Click Role and select Owners. Verify that there are only a few of them and that they are trusted and qualified. \n\nIn any case, only members with administrator or organization owner privileges can delete repositories, regardless of your setting.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "Only organization owners or members with admin privileges can delete repositories." + } + ] + }, + { + "Id": "1.2.4", + "Description": "Ensure only trusted and responsible users can delete issues.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.2 Repository Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure only trusted and responsible users can delete issues.", + "RationaleStatement": "Issues are a way to keep track of things happening in repositories, such as setting new milestones or requesting urgent fixes. Deleting an issue is not a benign activity, as it might harm the development workflow or attempt to hide malicious behavior. Because of this, it should be restricted and allowed only by trusted and responsible users.", + "ImpactStatement": "Certain users will not be permitted to delete issues.", + "RemediationProcedure": "Restrict issue deletion to a few trusted and responsible users only by performing either of the following steps:\n\nIf Your organizations > Settings > Access > Member privileges > Allow members to delete issues for this organization is selected, allow only trusted members to have admin privileges:\n1. In every repository, on GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings** and then in the \"Access\" section of the sidebar, click **Collaborators & teams**.\n3. Under \"Manage access\" use the dropdown **Role** menu to filter and search for admin members. Change their role by clicking the **Role** dropdown next to the username until there are only two trusted and qualified users with admin privileges.\n\nIf it is not selected, allow only trusted users to become an organization owner:\n1. In the top right corner of GitHub.com, click your profile photo, then click Your organizations.\n2. Click the name of your organization and under your organization name, click **People**.\n3. You will see a list of the people in your organization. Click **Role** and select **Owners**. Check every member you want to change permissions to. Above the list of members, use the drop-down menu and click **Change role** and choose Member > change role. Do that until there are only a few trusted and qualified users with organization owner privileges.\n\nIn any case, only members with administrator or organization owner privileges can delete issues, regardless of your setting.", + "AuditProcedure": "Verify that only a limited number of trusted users can delete issues by performing either of the following steps:\n\nIf Your organizations > Settings > Access > Member privileges > Allow members to delete issues for this organization is selected, verify that every admin member is trusted by you:\n1. In every repository, on GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings** and then in the \"Access\" section of the sidebar, click **Collaborators & teams**.\n3. Under \"Manage access\" use the dropdown **Role** menu to filter and search for admin members. Verify that there are only two of them and that they are trusted and qualified.\n\nIf it is not selected, verify that every organization owner is trusted by you:\n1. In the top right corner of GitHub.com, click your profile photo, then click Your organizations.\n2. Click the name of your organization and under your organization name, click **People**.\n3. You will see a list of the people in your organization. Click **Role** and select **Owners**. Verify that there are only a few of them and that they are trusted and qualified.\n\nIn any case, only members with administrator or organization owner privileges can delete issues, regardless of your setting.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "Only organization owners or members with admin privileges can delete issues." + } + ] + }, + { + "Id": "1.2.5", + "Description": "Track every fork of code and ensure it is accounted for.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.2 Repository Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Track every fork of code and ensure it is accounted for.", + "RationaleStatement": "A fork is a copy of a repository. On top of being a plain copy, any updates to the original repository itself can be pulled and reflected by the fork under certain conditions. A large number of repository copies (forks) become difficult to manage and properly secure. New and sensitive changes can often be pushed into a critical repository without developer knowledge of an updated copy of the very same repository. If there is no limit on doing this, then it is recommended to track and delete copies of organization repositories as needed.", + "ImpactStatement": "Disabling forks completely may slow down the development process as more actions will be necessary to take in order to fork a repository.", + "RemediationProcedure": "Track forks and examine them by performing the following on a regular basis:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Insights**.\n3. In the left sidebar, click **Forks**.\n4. Examine the forks listed there.", + "AuditProcedure": "Verify that the following steps are done regularly to track and examine forks:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Insights**.\n3. In the left sidebar, click **Forks**.\n4. Examine the forks listed there.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.2.6", + "Description": "Ensure every change in visibility of projects is tracked.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.2 Repository Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure every change in visibility of projects is tracked.", + "RationaleStatement": "Visibility of projects determines who can access a project and/or fork it: anyone, designated users, or only members of the organization. If a private project becomes public, this may point to a potential attack, which can ultimately lead to data loss, the leaking of sensitive information, and finally to a supply chain attack. It is crucial to track these changes in order to prevent such incidents.", + "ImpactStatement": "", + "RemediationProcedure": "Track every change in project visibility and investigate if suspicious behavior occurs, by performing the following regularly:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Archives\" section of the sidebar, click **Logs**, then click **Audit log**.\n4. Use the **repo** qualifier in your query and look for **access** category. Ensure every change is reasonable and secure and investigate if it is not.", + "AuditProcedure": "Ensure that every change in project visibility is tracked and investigated, by performing the following regularly:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Archives\" section of the sidebar, click **Logs**, then click **Audit log**.\n4. Use the **repo** qualifier in your query and look for **access** category. Ensure every change is reasonable and secure and is investigated if it is not.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.2.7", + "Description": "Track inactive repositories and remove them periodically.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.2 Repository Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Track inactive repositories and remove them periodically.", + "RationaleStatement": "Inactive repositories (i.e., no new changes introduced for a long period of time) can enlarge the surface of a potential attack or data leak. These repositories are more likely to be improperly managed, and thus could possibly be accessed by a large number of users in an organization.", + "ImpactStatement": "Bug fixes and deployment of necessary changes could prove complicated for archived repositories.", + "RemediationProcedure": "Perform the following to review all inactive repositories and archive them periodically:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Click on your organization name and then on **repositories**.\n3. Ensure every repository listed has been active in the last 3 to 6 months. Every repository that isn't active you should either review or archive by performing the next steps:\n 1. On GitHub.com, navigate to the main page of the repository.\n 2. Under your repository name, click **Settings**.\n 3. Under \"Danger Zone\", click **Archive this repository**.\n 4. Read the warnings.\n 5. Type the name of the repository you want to archive.\n 6. Click **I understand the consequences, archive this repository**.", + "AuditProcedure": "Perform the following to ensure that all the repositories in the organization are active, and those that are not reviewed or archived:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Click on your organization name and then on **repositories**.\n3. Ensure every repository listed has been active in the last 3 to 6 months. If it's not, then ensure it is archived or reviewed regularly.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.1", + "Description": "Track inactive user accounts and periodically remove them.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Track inactive user accounts and periodically remove them.", + "RationaleStatement": "User accounts that have been inactive for a long period of time are enlarging the surface of attack. Inactive users with high-level privileges are of particular concern, as these accounts are more likely to be targets for attackers. This could potentially allow access to large portions of an organization should such an attack prove successful. It is recommended to remove them as soon as possible in order to prevent this.", + "ImpactStatement": "", + "RemediationProcedure": "If you have GitHub Enterprise Cloud, perform the following:\n\n1. In the top-right corner of GitHub.com, click your profile photo, then click **Your enterprises**.\n2. In the list of enterprises, click the enterprise you want to view.\n3. In the enterprise account sidebar, click **Compliance**.\n4. To download your Dormant Users (beta) report as a CSV file, under \"Other\", click **Download**.\n5. Find the users listed in the file under **Your organizations** > your organization > **People** and select them.\n6. Click **Remove from organization** and **Remove members**.", + "AuditProcedure": "If you have GitHub Enterprise Cloud, perform the following:\n\n1. In the top-right corner of GitHub.com, click your profile photo, then click **Your enterprises**.\n2. In the list of enterprises, click the enterprise you want to view.\n3. In the enterprise account sidebar, click **Compliance**.\n4. To download your Dormant Users (beta) report as a CSV file, under \"Other\", click **Download**.\n5. Verify that there are no users listed.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.2", + "Description": "Limit ability to create teams to trusted and specific users.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Limit ability to create teams to trusted and specific users.", + "RationaleStatement": "The ability to create new teams should be restricted to specific members in order to keep the organization orderly and ensure users have access to only the lowest privilege level necessary. Teams typically inherit permissions from their parent team, thus if base permissions are less restricted and any user has the ability to create a team, a permission leverage could occur in which certain data is made available to users who should not have access to it. Such a situation could potentially lead to the creation of shadow teams by an attacker. Restricting team creation will also reduce additional clutter in the organizational structure, and as a result will make it easier to track changes and anomalies.", + "ImpactStatement": "Only specific users will be able to create new teams.", + "RemediationProcedure": "For every organization, limit team creation to specific, trusted users by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Access\" section of the sidebar, click **Member privileges**.\n4. Under \"Team creation rules\", deselect **Allow members to create teams**.\n5. Click **Save**.", + "AuditProcedure": "For every organization, ensure that team creation is limited to specific, trusted users by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Access\" section of the sidebar, click **Member privileges**.\n4. Under \"Team creation rules\", verify that **Allow members to create teams** is not selected.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.3", + "Description": "Ensure the organization has a minimum number of administrators.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure the organization has a minimum number of administrators.", + "RationaleStatement": "Organization administrators have the highest level of permissions, including the ability to add/remove collaborators, create or delete repositories, change branch protection policy, and convert to a publicly-accessible repository. Due to the permissive access granted to an organization administrator, it is highly recommended to keep the number of administrator accounts as minimal as possible.", + "ImpactStatement": "", + "RemediationProcedure": "Set the minimum number of administrators in your organization by performing the following:\n\n1. In the top right corner of GitHub, click your profile photo, then click **Your profile**.\n2. On the left side of your profile page, under \"Organizations\", click the icon for your organization.\n3. Under your organization name, click **People**. \n4. In the Role drop-down, choose **Owners**.\n5. Select the person or people you'd like to remove from owner role.\n6. Above the list of members, use the drop-down menu and click Change role.\n7. Select **Member**, then click **Change role**.", + "AuditProcedure": "Verify the minimum number of administrators in your organization by performing the following:\n\n1. In the top right corner of GitHub, click your profile photo, then click **Your profile**.\n2. On the left side of your profile page, under \"Organizations\", click the icon for your organization.\n3. Under your organization name, click **People**. \n4. In the Role drop-down, choose **Owners**.\n5. If there are minimum number of members in the list, you are compliant.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.4", + "Description": "Require collaborators from outside the organization to use Multi-Factor Authentication (MFA) in addition to a standard user name and password when authenticating to the source code management platform.", + "Checks": [ + "organization_members_mfa_required" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Require collaborators from outside the organization to use Multi-Factor Authentication (MFA) in addition to a standard user name and password when authenticating to the source code management platform.", + "RationaleStatement": "By default every user authenticates within the system by password only. If the password of a user is compromised, however, the user account and every repository to which they have access are in danger of data loss, malicious code commits, and data theft. It is therefore recommended that each user has Multi-Factor Authentication enabled. This adds an additional layer of protection to ensure the account remains secure even if the user's password is compromised.", + "ImpactStatement": "A member without enabled Multi-Factor Authentication cannot contribute to the project. They must enable Multi-Factor Authentication a before they can contribute any code.", + "RemediationProcedure": "For each repository in use, enforce Multi-Factor Authentication is the only way to authenticate for contributors, by doing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Authentication security**.\n4. Under \"Authentication\", select **Require two-factor authentication for everyone in your organization**, then click **Save**.", + "AuditProcedure": "For each repository in use, verify that Multi-Factor Authentication is enforced for contributors and is the only way to authenticate, by doing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Authentication security**.\n4. Under \"Authentication\", check if **Require two-factor authentication for everyone in your organization** is checked. If so, you are compliant.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.5", + "Description": "Require members of the organization to use Multi-Factor Authentication (MFA) in addition to a standard user name and password when authenticating to the source code management platform.", + "Checks": [ + "organization_members_mfa_required" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Require members of the organization to use Multi-Factor Authentication (MFA) in addition to a standard user name and password when authenticating to the source code management platform.", + "RationaleStatement": "By default every user authenticates within the system by password only. If the password of a user is compromised, however, the user account and every repository to which they have access are in danger of data loss, malicious code commits, and data theft. It is therefore recommended that each user has Multi-Factor Authentication enabled. This adds an additional layer of protection to ensure the account remains secure even if the user's password is compromised.", + "ImpactStatement": "Members will be removed from the organization if they don't have Multi-Factor Authentication already enabled. If this is the case, it is recommended that an invitation be sent to reinstate the user's access and former privileges. They must enable Multi-Factor Authentication to accept the invitation.", + "RemediationProcedure": "For every organization that exists in your GitHub platform, enforce Multi-Factor Authentication and define it as the only way to authenticate, by doing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Authentication security**.\n4. Under \"Authentication\", select **Require two-factor authentication for everyone in your organization**, then click **Save**.\n5. If prompted, read the information about members and outside collaborators who will be removed from the organization. Type your organization's name to confirm the change, then click **Remove members & require two-factor authentication**.", + "AuditProcedure": "For every organization that exists in your GitHub platform, verify that Multi-Factor Authentication is enforced and is the only way to authenticate, by doing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Authentication security**.\n4. Under \"Authentication\", check if **Require two-factor authentication for everyone in your organization** is checked. If so, you are compliant.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.6", + "Description": "Existing members of an organization can invite new members to join, however new members must only be invited with their company-approved email.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Existing members of an organization can invite new members to join, however new members must only be invited with their company-approved email.", + "RationaleStatement": "Ensuring new members of an organization have company-approved email prevents existing members of the organization from inviting arbitrary new users to join. Without this verification, they can invite anyone who is using the organization's version control system or has an active email account, thus allowing outside users (and potential threat actors) to easily gain access to company private code and resources. This practice will subsequently reduce the chance of human error or typos when inviting a new member.", + "ImpactStatement": "Existing members would not be able to invite new users who do not have a company-approved email address.", + "RemediationProcedure": "For each organization, allow only users with company-approved email to be invited. If a user was invited without company-approved email, perform the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Click the name of your organization and under your organization name, click **People**.\n3. On the People tab, click **Invitations**. Next to the username or email address of the person whose invitation you'd like to cancel, click **Edit invitation**.\n4. To cancel the user's invitation to join your organization, click **Cancel invitation**.", + "AuditProcedure": "For each organization in use, verify for every invitation that the invited email address is company-approved by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Click the name of your organization and under your organization name, click **People**.\n3. On the People tab, click **Invitations**. Verify that each invitation email is company approved by your company.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.7", + "Description": "Ensure every repository has two users with administrative permissions.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure every repository has two users with administrative permissions.", + "RationaleStatement": "Repository administrators have the highest permissions to said repository. These include the ability to add/remove collaborators, change branch protection policy, and convert to a publicly-accessible repository. Due to the liberal access granted to a repository administrator, it is highly recommended that only two contributors occupy this role.", + "ImpactStatement": "Removing administrative users from a repository would result in them losing high-level access to that repository.", + "RemediationProcedure": "For every repository in use, set two administrators by performing the following:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Access\" section of the sidebar, click **Collaborators & teams**.\n4. Under **Manage access**, find the team or person whose you'd like to revoke admin permissions, then select the Role drop-down and click a new role.", + "AuditProcedure": "For every repository in use, verify there are two administrators by performing the following:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Access\" section of the sidebar, click **Collaborators & teams**.\n4. Under **Manage access**, verify that there are only 2 members with Admin permission.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.8", + "Description": "Base permissions define the permission level automatically granted to all organization members. Define strict base access permissions for all of the repositories in the organization, including new ones.", + "Checks": [ + "organization_default_repository_permission_strict" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Base permissions define the permission level automatically granted to all organization members. Define strict base access permissions for all of the repositories in the organization, including new ones.", + "RationaleStatement": "Defining strict base permissions is the best practice in every role-based access control (RBAC) system. If the base permission is high — for example, \"write\" permission — every member of the organization will have \"write\" permission to every repository in the organization. This will apply regardless of the specific permissions a user might need, which generally differ between organization repositories. The higher the permission, the higher the risk for incidents such as bad code commit or data breach. It is therefore recommended to set the base permissions to the strictest level possible.", + "ImpactStatement": "Users might not be able to access organization repositories or perform some acts as commits. These specific permissions should be granted individually for each user or team, as needed.", + "RemediationProcedure": "Set strict base permissions for the organization repositories with the next steps:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Access\" section of the sidebar, click **Member privileges**.\n4. Under \"Base permissions\", use the drop-down to select new base permissions - \"Read\" or \"None\".\n5. Review the changes. To confirm, click **Change default permission to PERMISSION**.", + "AuditProcedure": "Verify that strict base permissions are set for the organization repositories by doing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Access\" section of the sidebar, click **Member privileges**.\n4. Under \"Base permissions\", verify that it is set to \"Read\" or \"None\". If it does, you are compliant.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "Read permission" + } + ] + }, + { + "Id": "1.3.9", + "Description": "Confirm the domains an organization owns with a \"Verified\" badge.", + "Checks": [ + "organization_verified_badge" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Confirm the domains an organization owns with a \"Verified\" badge.", + "RationaleStatement": "Verifying the organization's domain gives developers assurance that a given domain is truly the official home for a public organization. Attackers can pretend to be an organization and steal information via a faked/spoof domain, therefore the use of a \"Verified\" badge instills more confidence and trust between developers and the open-source community.", + "ImpactStatement": "", + "RemediationProcedure": "Only if you have an enterprise account, verify the organization's domains and secure a \"Verified\" badge next to its name by performing the following:\n\n1. In the top-right corner of GitHub.com, click your profile photo, then click **Your enterprises**.\n2. In the list of enterprises, click the enterprise you want to view. Then in the enterprise account sidebar, click **Settings**.\n3. Under \"Settings\", click **Verified & approved domains**.\n4. Click **Add a domain**.\n5. In the domain field, type the domain you'd like to verify, then click **Add domain**.\n6. Follow the instructions under **Add a DNS TXT record** to create a DNS TXT record with your domain hosting service. Wait for your DNS configuration to change, which may take up to 72 hours. You can confirm your DNS configuration has changed by running the `dig` command on the command line, replacing ENTERPRISE-ACCOUNT with the name of your enterprise account, and example.com with the domain you'd like to verify. You should see your new TXT record listed in the command output.\n```\ndig _github-challenge-ENTERPRISE-ACCOUNT.DOMAIN-NAME +nostats +nocomments +nocmd TXT\n```\n7. After confirming your TXT record is added to your DNS, follow steps one through three above to navigate to your enterprise account's approved and verified domains.\n8. To the right of the domain that's pending verification, click the 3-dots, then click **Continue verifying**. Click **Verify**.\n9. Optionally, after the \"Verified\" badge is visible on your organizations' profiles, delete the TXT entry from the DNS record at your domain hosting service.", + "AuditProcedure": "Only if you have an enterprise account, view the enterprise organization profile page and ensure it has a \"Verified\" badge in it.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/organizations/managing-organization-settings/verifying-or-approving-a-domain-for-your-organization", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.10", + "Description": "Restrict the Source Code Management (SCM) organization's email notifications to approved domains only.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Restrict the Source Code Management (SCM) organization's email notifications to approved domains only.", + "RationaleStatement": "Restricting Source Code Management email notifications to verified domains only prevents data leaks, as personal emails and custom domains are more prone to account takeover via DNS hijacking or password breach.", + "ImpactStatement": "Only members with approved email would be able to receive Source Code Management notifications.", + "RemediationProcedure": "Only if you have an enterprise account, restrict Source Code Management email notifications to approved domains only by performing the following:\n\n1. In the top-right corner of GitHub.com, click your profile photo, then click **Your enterprises**.\n2. In the list of enterprises, click the enterprise you want to view. Then in the enterprise account sidebar, click **Settings**.\n3. Under \"Settings\", click **Verified & approved domains**.\n4. Under \"Notification preferences\", select **Restrict email notifications to only approved or verified domains**.\n5. Click **Save**.", + "AuditProcedure": "Only if you have an enterprise account, ensure Source Code Management email notifications are restricted to approved domains only by performing the following:\n\n1. In the top-right corner of GitHub.com, click your profile photo, then click **Your enterprises**.\n2. In the list of enterprises, click the enterprise you want to view. Then in the enterprise account sidebar, click **Settings**.\n3. Under \"Settings\", click **Verified & approved domains**.\n4. Under \"Notification preferences\", verify that **Restrict email notifications to only approved or verified domains** is selected.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.11", + "Description": "As an organization, become an SSH Certificate Authority and provide SSH keys for accessing repositories.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "As an organization, become an SSH Certificate Authority and provide SSH keys for accessing repositories.", + "RationaleStatement": "There are two ways for remotely working with Source Code Management: via HTTPS, which requires authentication by user/password, or via SSH, which requires the use of SSH keys. SSH authentication is better in terms of security; key creation and distribution, however, must be done in a secure manner. This can be accomplished by implementing SSH certificates, which are used to validate the server's identity. A developer will not be able to connect to a Git server if its key cannot be verified by the SSH Certificate Authority (CA) server.\nAs an organization, one can verify the SSH certificate signature used to authenticate if a CA is defined and used. This ensures that only verified developers can access organization repositories, as their SSH key will be the only one signed by the CA certificate. This reduces the risk of misuse and malicious code commits.", + "ImpactStatement": "Members with unverified keys will not be able to clone organization repositories. Signing, certification, and verification might also slow down the development process.", + "RemediationProcedure": "Only if you have an enterprise account, deploy an SSH Certificate Authority server and configure it to provide an SSH certificate with which to sign keys by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Authentication security**.\n4. To the right of \"SSH Certificate Authorities\", click **New CA**.\n5. Under \"Key,\" paste your public SSH key.\n6. Click **Add CA**.\n7. Click **Save**.", + "AuditProcedure": "Only if you have an enterprise account, verify that the enterprise organization has an SSH Certificate Authority server and provides an SSH certificate with which to sign keys by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Authentication security**.\n4. Verify that there's an SSH certificate authority listed there.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.12", + "Description": "Limit Git access based on IP addresses by having a allowlist of IP addresses from which connection is possible.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Limit Git access based on IP addresses by having a allowlist of IP addresses from which connection is possible.", + "RationaleStatement": "Allowing access to Git repositories (source code) only from specific IP addresses adds yet another layer of restriction and reduces the risk of unauthorized connection to the organization's assets. This will prevent attackers from accessing Source Code Management (SCM), as they would first need to know the allowed IP addresses to gain access to them.", + "ImpactStatement": "Only members with allowlisted IP addresses will be able to access the organization's Git repositories.", + "RemediationProcedure": "Only if you have an enterprise account, create an IP allowlist and forbid all other IPs from accessing the source code by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Authentication security**.\n4. At the bottom of the \"IP allow list\" section, enter an IP address, or a range of addresses in CIDR notation. Optionally, enter a description of the allowed IP address or range.\n5. Click **Add**.\n6. After that, under \"IP allow list\", select **Enable IP allow list**.\n7. Click **Save**.", + "AuditProcedure": "Only if you have an enterprise account, in every organization of yours, ensure access is allowed only by IP allowlist, and that access is forbidden for all other IPs by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Authentication security**.\n4. Verify that there's an IP address, or a range of addresses in CIDR notation listed. Also verify that **Enable IP allow list** is selected.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-allowed-ip-addresses-for-your-organization", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.13", + "Description": "Track code anomalies.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Track code anomalies.", + "RationaleStatement": "Carefully analyze any code anomalies within the organization. For example, a code anomaly could be a push made outside of working hours. Such a code push has a higher likelihood of being the result of an attack, as most if not all members of the organization would likely be outside the office. Another example is an activity that exceeds the average activity of a particular user.\nTracking and auditing such behaviors creates additional layers of security and can aid in early detection of potential attacks.", + "ImpactStatement": "", + "RemediationProcedure": "For every repository in use, track and investigate anomalous code behavior and activity.", + "AuditProcedure": "For every repository in use, ensure code anomalies relevant to the organization are promptly investigated.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.4.1", + "Description": "Ensure an administrator approval is required when installing applications.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.4 Third-Party", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure an administrator approval is required when installing applications.", + "RationaleStatement": "Applications are typically automated integrations that improve the workflow of an organization. They are written by third-party developers, and therefore should be validated before using in case they're malicious or not trustable. Because administrators are expected to be the most qualified and trusted members of the organization, they should review the applications being installed and decide whether they are both trusted and necessary.", + "ImpactStatement": "Applications will not be installed without administrator approval.", + "RemediationProcedure": "Require an administrator approval for every installed application:\n\na. For GitHub Apps, you are compliant by default. That is because by default only organization owners and administrators can install them.\n\nb. For OAuth Apps, perform the following:\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Third-party Access\" section of the sidebar, click **OAuth application policy**.\n4. Under \"Third-party application access policy\", click **Setup application access restrictions**.\n5. After you review the information about third-party access restrictions, click **Restrict third-party application access**.", + "AuditProcedure": "Verify that applications are installed only after receiving administrator approval:\n\na. For GitHub Apps, you are compliant by default. That is because by default only organization owners and administrators can install them.\n\nb. For OAuth Apps, perform the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Third-party Access\" section of the sidebar, click **OAuth application policy**.\n4. Under \"Third-party application access policy\" verify that the policy status is **Access restricted**.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "Maintainers are organization owners." + } + ] + }, + { + "Id": "1.4.2", + "Description": "Ensure stale (inactive) applications are reviewed and removed if no longer in use.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.4 Third-Party", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure stale (inactive) applications are reviewed and removed if no longer in use.", + "RationaleStatement": "Applications that have been inactive for a long period of time are enlarging the surface of attack for data leaks. They are more likely to be improperly managed, and could possibly be accessed by third-party developers as a tool for collecting internal data of the organization or repository in which they are installed. It is important to remove these inactive applications as soon as possible.", + "ImpactStatement": "", + "RemediationProcedure": "Review all stale applications and periodically remove them.", + "AuditProcedure": "Verify that all the applications in the organization are actively used, and remove those that are no longer in use.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.4.3", + "Description": "Ensure installed application permissions are limited to the lowest privilege level required.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.4 Third-Party", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure installed application permissions are limited to the lowest privilege level required.", + "RationaleStatement": "Applications are typically automated integrations that can improve the workflow of an organization. They are written by third-party developers, and therefore should be reviewed carefully before use. It is recommended to use the \"least privilege\" principle, granting applications the lowest level of permissions required. This may prevent harm from a potentially malicious application with unnecessarily high-level permissions leaking data or modifying source code.", + "ImpactStatement": "", + "RemediationProcedure": "Grant permissions to applications by the \"least privilege\" principle, meaning the lowest possible permission necessary:\n\na. For GitHub Apps, perform the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Integrations\" section of the sidebar, click **GitHub Apps**.\n4. Next to every GitHub App, click **Configure**.\n5. Review the GitHub App's permissions and repository access. Edit the permissions granted to the least possible. For example, restrict the number of repositories the App can access.\n6. Click **Save**.", + "AuditProcedure": "Verify that each installed application has the least privilege needed:\n\na. For GitHub Apps, perform the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Integrations\" section of the sidebar, click **GitHub Apps**.\n4. Next to every GitHub App, click **Configure**.\n5. Review the GitHub App's permissions and repository access. Verify that the App permissions are the least possible and that it can access only necessary repositories.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.4.4", + "Description": "Use only secured webhooks in the source code management platform.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.4 Third-Party", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Use only secured webhooks in the source code management platform.", + "RationaleStatement": "A webhook is an event listener, attached to critical and sensitive parts of the software delivery process. It is triggered by a list of events (such as a new code being committed), and when triggered, the webhook sends out a notification with some payload to specific internet endpoints. Since the payload of the webhook contains sensitive organization data, it's important all webhooks are directed to an endpoint (URL) protected by SSL verification (HTTPS). This helps ensure that the data sent is delivered to securely without any man-in-the-middle, who could easily access and even alter the payload of the request.", + "ImpactStatement": "Perform the following to ensure all webhooks used are secured (HTTPS):\n\n1. Navigate to your organization or repository and select **Settings**.\n2. Select **Webhooks** on the side menu.\n3. Verify that each webhook URL starts with 'https'.", + "RemediationProcedure": "Perform the following to secure all webhooks used(over HTTPS):\n\n1. Navigate to your organization or repository and select **Settings**.\n2. Select **Webhooks** on the side menu.\n3. Find the webhooks that starts with 'http' and not 'https'.\n4. Ensure the endpoint (URL) of the webhook listens to secured port (443) and uses certificate.\n5. Click **Edit**.\n6. Change the payload URL to https and ensure **Enable SSL verification** is checked.\n7. Click **Update webhook**.", + "AuditProcedure": "Perform the following to secure all webhooks used(over HTTPS):\n\n1. Navigate to your organization or repository and select **Settings**.\n2. Select **Webhooks** on the side menu.\n3. Ensure all webhooks starts with 'https'.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.5.1", + "Description": "Detect and prevent sensitive data in code, such as confidential ID numbers, passwords, etc.", + "Checks": [ + "repository_secret_scanning_enabled" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.5 Code Risks", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Detect and prevent sensitive data in code, such as confidential ID numbers, passwords, etc.", + "RationaleStatement": "Having sensitive data in the source code makes it easier for attackers to maliciously use such information. In order to avoid this, designate scanners to identify and prevent the existence of sensitive data in the code.", + "ImpactStatement": "", + "RemediationProcedure": "For every repository in use, designate scanners to identify and prevent sensitive data in code by performing the following (enterprise only):\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Code security and analysis**.\n4. Scroll down to the bottom of the page and click **Enable** for secret scanning.", + "AuditProcedure": "For every repository in use, verify that scanners are set to identify and prevent the existence of sensitive data in code by performing the following (enterprise only):\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Code security and analysis**.\n4. Scroll down to the bottom of the page. If you see a **Disable** button, it means that secret scanning is already enabled for the repository.", + "AdditionalInformation": "By January 2023, this feature is supposed to be open to all plans. Until then it is only for enterprise users.", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.5.2", + "Description": "Detect and prevent misconfigurations and insecure instructions in CI pipelines", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.5 Code Risks", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Detect and prevent misconfigurations and insecure instructions in CI pipelines", + "RationaleStatement": "Detecting and fixing misconfigurations or insecure instructions in CI pipelines decreases the risk for a successful attack through or on the CI pipeline. The more secure the pipeline, the less risk there is for potential exposure of sensitive data, a deployment being compromised, or external access mistakenly being granted to the CI infrastructure or the source code.", + "ImpactStatement": "", + "RemediationProcedure": "Set a CI instructions scanning tool to identify and prevent misconfigurations and insecure instructions and scans all CI pipelines.", + "AuditProcedure": "Verify that a CI instructions scanning tool is set to identify and prevent misconfigurations and insecure instructions and that it scans all CI pipelines.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.5.3", + "Description": "Detect and prevent misconfigurations or insecure instructions in Infrastructure as Code (IaC) files, such as Terraform files.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.5 Code Risks", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Detect and prevent misconfigurations or insecure instructions in Infrastructure as Code (IaC) files, such as Terraform files.", + "RationaleStatement": "Detecting and fixing misconfigurations and/or insecure instructions in IaC (Infrastructure as Code) files decreases the risk for data leak or data theft. It is important to secure IaC instructions in order to prevent further problems of deployment, exposed assets, or improper configurations, which can ultimately lead to easier ways to attack and steal organization data.", + "ImpactStatement": "", + "RemediationProcedure": "For every repository that holds IaC instructions files, set a scanning tool to identify and prevent misconfigurations and insecure instructions.", + "AuditProcedure": "For every repository that holds IaC instructions files, verify that a scanning tool is set to identify and prevent misconfigurations and insecure instructions.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.5.4", + "Description": "Detect and prevent known open source vulnerabilities in the code.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.5 Code Risks", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Detect and prevent known open source vulnerabilities in the code.", + "RationaleStatement": "Open source code blocks are used a lot in developed software. This has its own advantages, but it also has risks. Because the code is open for everyone, it means that attackers can publish or add malicious code to these open-source code blocks, or use their knowledge to find vulnerabilities in an existing code. Detecting and fixing such code vulnerabilities, by SCA (Software Composition Analysis) prevents insecure flaws from reaching production. It gives another opportunity for developers to secure the source code before it is deployed in production, where it is far more exposed and vulnerable to attacks.", + "ImpactStatement": "", + "RemediationProcedure": "For every repository that is in use, set a scanning tool to identify and prevent code vulnerabilities by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under the repository name, click **Security**.\n3. To the right of \"Code scanning alerts\", click **Set up code scanning**.\n4. Under \"Get started with code scanning\", click Set up this workflow on a workflow of your choice.\n5. To customize how code scanning scans your code, edit the workflow.\n6. Use the **Start commit** drop-down and type a commit message.\n7. Choose whether you'd like to commit directly to the default branch or create a new branch and start a pull request.\n8. Click **Commit new file** or **Propose new file**.", + "AuditProcedure": "For every repository that is in use, verify that a scanning tool is set to identify and prevent code vulnerabilities by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under the repository name, click **Security**.\n3. Verify that \"Code scanning alerts\" is **Enabled** or that there is a workflow that scans your code.", + "AdditionalInformation": "All public repositories in all plans can use code scanning in GitHub. In private repositories, only GitHub Enterprise can use code scanning.", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.5.5", + "Description": "Detect, prevent and monitor known open-source vulnerabilities in packages that are being used.", + "Checks": [ + "repository_dependency_scanning_enabled" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.5 Code Risks", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Detect, prevent and monitor known open-source vulnerabilities in packages that are being used.", + "RationaleStatement": "Open-source vulnerabilities might exist before one starts to use a package, but they are also discovered over time. New attacks and vulnerabilities are announced every now and then. It is important to keep track of these and to monitor whether the dependencies used are affected by the recent vulnerability. Detecting and fixing those packages' vulnerabilities decreases the attack surface within deployed and running applications that use such packages. It prevents security flaws from reaching the production environment which could eventually lead to a security breach.", + "ImpactStatement": "", + "RemediationProcedure": "Set scanners that will monitor, identify, and prevent open-source vulnerabilities in packages by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**. In the \"Security\" section of the sidebar, click **Code security and analysis**.\n3. Under \"Code security and analysis\", to the right of Dependabot alerts, click **Disable all** or **Enable all**.\n4. Check the **Enable by default for new repositories** option and then click **Enable Dependabot alerts**.\n\nIt is also recommended to use another additional solution for package scanning because GitHub doesn't always recognize the vulnerabilities.", + "AuditProcedure": "Verify that scanners are set to monitor, identify, and prevent open-source vulnerabilities in packages by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**. In the \"Security\" section of the sidebar, click **Code security and analysis**.\n3. Verify that the Dependabot alerts is enabled and that **Enable by default for new repositories** is checked.\n\nIt is also recommended to verify that you're using another additional solution for package scanning because GitHub doesn't always recognize the vulnerabilities.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.5.6", + "Description": "Detect open-source license problems in used dependencies and fix them.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.5 Code Risks", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Detect open-source license problems in used dependencies and fix them.", + "RationaleStatement": "A software license is a legal document that establishes several key conditions between a software company or developer and a user in order to allow the use of software. Software licenses have the potential to create code dependencies. Not following the conditions in the software license can also lead to lawsuits. When using packages with a software license, especially commercial ones (which are the most permissive), it is important to verify what is allowed by that license in order to be protected against lawsuits.", + "ImpactStatement": "", + "RemediationProcedure": "Designate a license scanning tool to identify open-source license problems and fix them and scan every package you use.", + "AuditProcedure": "Ensure a license scanning tool is set up to identify open-source license problems and that every package you use is scanned by it.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.1", + "Description": "Ensure each pipeline has a single responsibility in the build process.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure each pipeline has a single responsibility in the build process.", + "RationaleStatement": "Build pipelines generally have access to multiple secrets depending on their purposes. There are, for example, secrets of the test environment for the test phase, repository and artifact credentials for the build phase, etc. Limiting access to these credentials/secrets is therefore recommended by dividing pipeline responsibilities, as well as having a dedicated pipeline for each phase with the lowest privilege instead of a single pipeline for all. This will ensure that any potential damage caused by attacks on a workflow will be limited.", + "ImpactStatement": "", + "RemediationProcedure": "Divide each multi-responsibility pipeline into multiple pipelines, each having a single responsibility with the least privilege. Additionally, create all new pipelines with a sole purpose going forward.", + "AuditProcedure": "For each pipeline, ensure it has only one responsibility in the build process.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.2", + "Description": "Ensure the pipeline orchestrator and its configuration are immutable.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure the pipeline orchestrator and its configuration are immutable.", + "RationaleStatement": "An immutable infrastructure is one that cannot be changed during execution of the pipeline. This can be done, for example, by using Infrastructure as Code for configuring the pipeline and the pipeline environment. Utilizing such infrastructure creates a more predictable environment because updates will require re-deployment to prevent any previous configuration from interfering. Because it is dependent on automation, it is easier to revert changes. Testing code is also simpler because it is based on virtualization. Most importantly, an immutable pipeline infrastructure ensures that a potential attacker seeking to compromise the build environment itself would not be able to do so if the orchestrator, its configuration, and any other component cannot be changed. Verifying that all aspects of the pipeline infrastructure and configuration are immutable therefore keeps them safe from malicious tampering attempts.", + "ImpactStatement": "", + "RemediationProcedure": "Use an immutable pipeline orchestrator and ensure that its configuration and all other aspects of the built environment are immutable, as well.", + "AuditProcedure": "Verify that the pipeline orchestrator, its configuration, and all other aspects of the build environment are immutable.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.3", + "Description": "Keep build logs of the build environment detailing configuration and all activity within it. Also, consider to store them in a centralized organizational log store.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Keep build logs of the build environment detailing configuration and all activity within it. Also, consider to store them in a centralized organizational log store.", + "RationaleStatement": "Logging the environment is important for two primary reasons: one, for debugging and investigating the environment in case of a bug or security incident; and two, for reproducing the environment easily when needed. Storing these logs in a centralized organizational log store allows the organization to generate useful insights and identify anomalies in the build process faster.", + "ImpactStatement": "", + "RemediationProcedure": "Keep logs of the build environment. For example, use the .buildinfo file for Debian build workers. Also, store the logs in a centralized organizational log store.", + "AuditProcedure": "Verify that the build environment is logged and stored in a centralized organizational log store.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.4", + "Description": "Automate the creation of the build environment.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Automate the creation of the build environment.", + "RationaleStatement": "Automating the deployment of the build environment reduces the risk for human mistakes — such as a wrong configuration or exposure of sensitive data — because it requires less human interaction and intervention. It also eases re-deployment of the environment. It is best to automate with Infrastructure as Code because it offers more control over changes made to the environment creation configuration and stores to a version control platform.", + "ImpactStatement": "", + "RemediationProcedure": "Automate the deployment of the build environment.", + "AuditProcedure": "Verify that the deployment of the build environment is automated and can be easily redeployed.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.5", + "Description": "Restrict access to the build environment (orchestrator, pipeline executor, their environment, etc.) to trusted and qualified users only.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Restrict access to the build environment (orchestrator, pipeline executor, their environment, etc.) to trusted and qualified users only.", + "RationaleStatement": "A build environment contains sensitive data such as environment variables, secrets, and the source code itself. Any user that has access to this environment can make changes to the build process, including changes to the code within it. Restricting access to the build environment to trusted and qualified users only will reduce the risk for mistakes such as exposure of secrets or misconfiguration. Limiting access also reduces the number of accounts that are vulnerable to hijacking in order to potentially harm the build environment.", + "ImpactStatement": "Reducing the number of users who have access to the build process means those users would lose their ability to make direct changes to that process.", + "RemediationProcedure": "Restrict access to the build environment to trusted and qualified users.", + "AuditProcedure": "Verify each build environment is accessible only to known and authorized users.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.6", + "Description": "Require users to login in to access the build environment - where the orchestrator, the pipeline executer, where the build workers are running, etc.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Require users to login in to access the build environment - where the orchestrator, the pipeline executer, where the build workers are running, etc.", + "RationaleStatement": "Requiring users to authenticate and disabling anonymous access to the build environment allows organization to track every action on that environment, good or bad, to its actor. This will help recognizing attack and its attacker because the authentication is required.", + "ImpactStatement": "Anonymous users won't be able to access the build environment.", + "RemediationProcedure": "Require authentication to access the build environment and disable anonymous access.", + "AuditProcedure": "Ensure authentication is required to access the build environment.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.7", + "Description": "Build tools providers offer a secure way to store secrets that should be used during the build process.\nThese secrets will often be credentials used to access other tools, for example for pulling code or for uploading artifacts.\nAccess to these secrets can be defined on various scopes, for example in github it could be on an organization level or a repository level and there is also control on whether these secrets are passed to forked pull request.\nTo protect these critical assets it is important to choose the most restrictive scope necessary.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Build tools providers offer a secure way to store secrets that should be used during the build process.\nThese secrets will often be credentials used to access other tools, for example for pulling code or for uploading artifacts.\nAccess to these secrets can be defined on various scopes, for example in github it could be on an organization level or a repository level and there is also control on whether these secrets are passed to forked pull request.\nTo protect these critical assets it is important to choose the most restrictive scope necessary.", + "RationaleStatement": "Allowing over permissive access to these secrets may affect on their exposure.\nFor example if a secret is defined in an organization level, and users can create new repositories, there is a scenario where a user can create a new repo and run a controlled build just to exfiltrate these secrets.", + "ImpactStatement": "Increased risk of exposure of build related secrets.", + "RemediationProcedure": "For each build tool, review the secrets defined and their permissions scope and change over permissive scopes to more restrictive ones based on the required access.", + "AuditProcedure": "For each build tool in use, review the secrets defined and the permission scopes they are assigned.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.8", + "Description": "Scan the build infrastructure and its dependencies for vulnerabilities. It is recommended that this be done automatically.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Scan the build infrastructure and its dependencies for vulnerabilities. It is recommended that this be done automatically.", + "RationaleStatement": "Automatic scanning for vulnerabilities detects known vulnerabilities in the tooling used by the build infrastructure and its dependencies. These vulnerabilities can lead\nto a potentially massive breach if not handled as fast as possible, as attackers might also be\naware of such vulnerabilities.", + "ImpactStatement": "", + "RemediationProcedure": "Set an automated vulnerability scanning for your build infrastructure.", + "AuditProcedure": "Verify that your build infrastructure is automatically scanned for vulnerabilities.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.9", + "Description": "Do not use default passwords of build tools and components.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not use default passwords of build tools and components.", + "RationaleStatement": "Sometimes build tools and components are provided with default passwords for the first login. This password is intended to be used only on the first login and should be changed immediately after. Using the default password substantially increases the attack risk. It is especially important to ensure that default passwords are not used in build tools and components.", + "ImpactStatement": "", + "RemediationProcedure": "For each build tool, change the default password.", + "AuditProcedure": "For each build tool, ensure the password used is not the default one.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.10", + "Description": "Use secured webhooks of the build environment.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Use secured webhooks of the build environment.", + "RationaleStatement": "Webhooks are used for triggering an HTTP request based on an action made in the platform. Typically, build environment feature webhooks for a pipeline trigger based on source code event. Since webhooks are an HTTP POST request, they can be malformed if not secured over SSL. To prevent a potential hack and compromise of the webhook or to the environment or web server excepting the request, use only secured webhooks.", + "ImpactStatement": "", + "RemediationProcedure": "For each webhook in use, change it to secured (over HTTPS).", + "AuditProcedure": "For each webhook in use, ensure it is secured (HTTPS).", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.11", + "Description": "Ensure the build environment has a minimum number of administrators.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure the build environment has a minimum number of administrators.", + "RationaleStatement": "Build environment administrators have the highest level of permissions, including the ability to add/remove users, create or delete pipelines, control build workers, change build trigger permissions and more. Due to the permissive access granted to a build environment administrator, it is highly recommended to keep the number of administrator accounts as minimal as possible.", + "ImpactStatement": "", + "RemediationProcedure": "Set the minimum number of administrators in the build environment.", + "AuditProcedure": "Verify that the build environment has only the minimum number of administrators.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.2.1", + "Description": "Use a clean instance of build worker for every pipeline run.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.2 Build Worker", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Use a clean instance of build worker for every pipeline run.", + "RationaleStatement": "Using a clean instance of build worker for every pipeline run eliminates the risks of data theft, data integrity breaches, and unavailability. It limits the pipeline's access to data stored on the file system from previous runs, and the cache is volatile. This prevents malicious changes from affecting other pipelines or the Continuous Integration/Continuous Delivery system itself.", + "ImpactStatement": "Data and cache will not be saved in different pipeline runs.", + "RemediationProcedure": "Create a clean build worker for every pipeline that is being run, or use build platform-hosted runners, as they typically offer a clean instance for every run.", + "AuditProcedure": "Ensure that every pipeline that is being run has its own clean, new runner.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.2.2", + "Description": "A worker’s environment can be passed (for example, a pod in a Kubernetes cluster in which an environment variable is passed to it). It also can be pulled, like a virtual machine that is installing a package. Ensure that the environment and commands are passed to the workers and not pulled from it.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.2 Build Worker", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "A worker’s environment can be passed (for example, a pod in a Kubernetes cluster in which an environment variable is passed to it). It also can be pulled, like a virtual machine that is installing a package. Ensure that the environment and commands are passed to the workers and not pulled from it.", + "RationaleStatement": "Passing an environment means additional configuration happens in the build time phase and not in run time. It will also pass locally and not remotely. Passing a worker environment, instead of pulling it from an outer source, reduces the possibility for an attacker to gain access and potentially pull malicious code into it. By passing locally and not pulling from remote, there is also less chance of an attack based on the remote connection, such as a man-in-the-middle or malicious scripts that can run from remote. This therefore prevents possible infection of the build worker.", + "ImpactStatement": "", + "RemediationProcedure": "For each build worker, pass its environment and commands to it instead of pulling it.", + "AuditProcedure": "For each build worker, ensure its environment and commands are passed and not pulled.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.2.3", + "Description": "Separate responsibilities in the build workflow, such as testing, compiling, pushing artifacts, etc., to different build workers so that each worker will have a single duty.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.2 Build Worker", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Separate responsibilities in the build workflow, such as testing, compiling, pushing artifacts, etc., to different build workers so that each worker will have a single duty.", + "RationaleStatement": "Separating duties and allocating them to many workers makes it easier to verify each step in the build process and ensure there is no corruption. It also limits the effect of an attack on a build worker, as such an attack would be less critical if the worker has less access and duties that are subject to harm.", + "ImpactStatement": "", + "RemediationProcedure": "For each build worker, limit its responsibility to one duty.", + "AuditProcedure": "For each build worker, ensure it has the least responsibility possible, preferably only one duty.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.2.4", + "Description": "Ensure that build workers have minimal network connectivity.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.2 Build Worker", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that build workers have minimal network connectivity.", + "RationaleStatement": "Restricting the network connectivity of build workers decreases the possibility that an attacker would be capable of entering the organization from the outside. If the build workers are connected to the public internet without any restriction, it is far simpler for attackers to compromise them. Limiting network connectivity between build workers also protects the organization in case an attacker was successful and subsequently attempts to spread the attack to other components of the environment.", + "ImpactStatement": "Developers will not have connectivity to every resource they might need from the outside. Workers will also only be able to exchange data through shareable storage.", + "RemediationProcedure": "Limit the network connectivity of build workers, environment, and any other components to the necessary minimum.", + "AuditProcedure": "Verify that build workers, environment, and any other components have only the required minimum of network connectivity.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.2.5", + "Description": "Add traces to build workers' operating systems and installed applications so that in run time, collected events can be analyzed to detect suspicious behavior patterns and malware.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.2 Build Worker", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Add traces to build workers' operating systems and installed applications so that in run time, collected events can be analyzed to detect suspicious behavior patterns and malware.", + "RationaleStatement": "Build workers are exposed to data exfiltration attacks, code injection attacks, and more while running. It is important to secure them from such attacks by enforcing run-time security on the build worker itself. This will identify attempted attacks in real time and prevent them.", + "ImpactStatement": "", + "RemediationProcedure": "Deploy and enforce a run-time security solution on build workers.", + "AuditProcedure": "Verify that a run-time security solution is enforced on every active build worker.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.2.6", + "Description": "Scan build workers for vulnerabilities. It is recommended that this be done automatically.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.2 Build Worker", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Scan build workers for vulnerabilities. It is recommended that this be done automatically.", + "RationaleStatement": "Automatic scanning for vulnerabilities detects known weaknesses in environmental sources in use, such as docker images or kernel versions. Such vulnerabilities can lead to a massive breach if these environments are not replaced as fast as possible, since attackers also know about these vulnerabilities and often try to take advantage of them. Setting automatic scanning which scans environmental sources ensures that if any new vulnerability is revealed, it can be replaced quickly and easily. This protects the worker from being exposed to attacks.", + "ImpactStatement": "", + "RemediationProcedure": "For each build worker, automatically scan its environmental sources, such as docker image, for vulnerabilities.", + "AuditProcedure": "For each build worker, ensure the environmental sources it uses are scanned for vulnerabilities.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.2.7", + "Description": "Store the deployment configuration of build workers in a version control platform, such as Github.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.2 Build Worker", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Store the deployment configuration of build workers in a version control platform, such as Github.", + "RationaleStatement": "Build workers are a sensitive part of the build phase. They generally have access to the code repository, the Continuous Integration platform, the deployment platform, etc. This means that an attacker gaining access to a build worker may compromise other platforms in the organization and cause a major incident. One thing that can protect workers is to ensure that their deployment configuration is safe and well-configured. Storing the deployment configuration in version control enables more observability of these configurations because everything is catalogued in a single place. It adds another layer of security, as every change will be reviewed and noticed, and thus malicious changes will theoretically occur less. In the case of a mistake, bug, or security incident, it also offers an easier way to \"revert\" back to a safe version or add a \"hot fix\" quickly.", + "ImpactStatement": "Changes in deployment configuration may only be applied by declaration in the version control platform. This could potentially slow down the development process.", + "RemediationProcedure": "Document and store every deployment configuration of build workers in a version control platform.", + "AuditProcedure": "Verify that the deployment configuration of build workers is stored in a version control platform.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.2.8", + "Description": "Monitor the resource consumption of build workers and set alerts for high consumption that can lead to resource exhaustion.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.2 Build Worker", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Monitor the resource consumption of build workers and set alerts for high consumption that can lead to resource exhaustion.", + "RationaleStatement": "Resource exhaustion is when machine resources or services are highly consumed until exhausted. Resource exhaustion may lead to DOS (Denial of Service). When such a situation happens to build workers, it slows down and even stops the build process, which harms the production of artifacts and the organization's ability to deliver software on schedule. To prevent that, it is recommended to monitor resources consumption in the build workers and set alerts to notify when they are highly consumed. That way resource exhaustion can be acknowledged and prevented at an early stage.", + "ImpactStatement": "", + "RemediationProcedure": "Set reources consumption monitoring for each build worker.", + "AuditProcedure": "Verify that there is monitoring of resources consumption for each build worker.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.3.1", + "Description": "Use pipeline as code for build pipelines and their defined steps.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.3 Pipeline Instructions", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Use pipeline as code for build pipelines and their defined steps.", + "RationaleStatement": "Storing pipeline instructions as code in a version control system means automation of the build steps and less room for human error, which could potentially lead to a security breach. Additionally, It creates the ability to revert back to a previous pipeline configuration in order to pinpoint the affected change should a malicious incident occur.", + "ImpactStatement": "", + "RemediationProcedure": "Convert pipeline instructions into code-based syntax and upload them to the organization's version control platform.", + "AuditProcedure": "Verify that all build steps are defined as code and stored in a version control system.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.3.2", + "Description": "Define clear expected input and output for each build stage.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.3 Pipeline Instructions", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Define clear expected input and output for each build stage.", + "RationaleStatement": "In order to have more control over data flow in the build pipeline, clearly define the input and output of the pipeline steps. If anything malicious happens during the build stage, it will be recognized more easily and stand out as an anomaly.", + "ImpactStatement": "", + "RemediationProcedure": "For each build stage, clearly define what is expected for input and output.", + "AuditProcedure": "For each build stage, verify that the expected input and output are clearly defined.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.3.3", + "Description": "Write pipeline output artifacts to a secured storage repository.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.3 Pipeline Instructions", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Write pipeline output artifacts to a secured storage repository.", + "RationaleStatement": "To maintain output artifacts securely and reduce the potential surface for attack, store such artifacts separately in secure storage. This separation enforces the Single Responsibility Principle by ensuring the orchestration platform will not be the same as the artifact storage, which reduces the potential harm of an attack. Using the same security considerations as the input (for example, the source code) will protect artifacts stored and will make it harder for a malicious actor to successfully execute an attack.", + "ImpactStatement": "", + "RemediationProcedure": "For each pipeline that produces output artifacts, write them to a secured storage repository.", + "AuditProcedure": "For each pipeline that produces output artifacts, ensure that they're written to a secured storage repository.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.3.4", + "Description": "Track and review changes to pipeline files.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.3 Pipeline Instructions", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Track and review changes to pipeline files.", + "RationaleStatement": "Pipeline files are sensitive files. They have the ability to access sensitive data and control the build process, thus it is just as important to review changes to pipeline files as it is to verify source code. Malicious actors can potentially add harmful code to these files, which may lead to sensitive data exposure and hijacking of the build environment or artifacts.", + "ImpactStatement": "", + "RemediationProcedure": "For each pipeline file, track changes to it and review them.", + "AuditProcedure": "For each pipeline file, ensure changes to it are being tracked and reviewed.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.3.5", + "Description": "Restrict access to pipeline triggers.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.3 Pipeline Instructions", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Restrict access to pipeline triggers.", + "RationaleStatement": "Build pipelines are used for multiple reasons. Some are very sensitive, such as pipelines which deploy to production. In order to protect the environment from malicious acts or human mistakes, such as a developer deploying a bug to production, it is important to apply the Least Privilege principle to pipeline triggering. This principle requires restrictions placed on which users can run which pipeline. It allows for sensitive pipelines to only be run by administrators, who are generally the most trusted and skilled members of the organization.", + "ImpactStatement": "", + "RemediationProcedure": "For every pipeline in use, grant only the necessary users permission to trigger it.", + "AuditProcedure": "For every pipeline in use, verify only the necessary users have permission to trigger it.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.3.6", + "Description": "Scan the pipeline for misconfigurations. It is recommended that this be performed automatically.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.3 Pipeline Instructions", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Scan the pipeline for misconfigurations. It is recommended that this be performed automatically.", + "RationaleStatement": "Automatic scans for misconfigurations detect human mistakes and misconfigured tasks. This protects the environment from backdoors caused by such mistakes, which create easier access for attackers. For example, a task that mistakenly configures credentials to persist on the disk makes it easier for an attacker to steal them. This type of incident can be prevented by auto-scanning.", + "ImpactStatement": "", + "RemediationProcedure": "For each pipeline, set automated misconfiguration scanning.", + "AuditProcedure": "For each pipeline, verify that it is automatically scanned for misconfigurations.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.3.7", + "Description": "Scan pipelines for vulnerabilities. It is recommended that this be implemented automatically.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.3 Pipeline Instructions", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Scan pipelines for vulnerabilities. It is recommended that this be implemented automatically.", + "RationaleStatement": "Automatic scanning for vulnerabilities detects known vulnerabilities in pipeline instructions and components, allowing faster patching in case one is found. These vulnerabilities can lead to a potentially massive breach if not handled as fast as possible, as attackers might also be aware of such vulnerabilities.", + "ImpactStatement": "", + "RemediationProcedure": "For each pipeline, set automated vulnerability scanning.", + "AuditProcedure": "For each pipeline, verify that it is automatically scanned for vulnerabilities.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.3.8", + "Description": "Detect and prevent sensitive data, such as confidential ID numbers, passwords, etc., in pipelines.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.3 Pipeline Instructions", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Detect and prevent sensitive data, such as confidential ID numbers, passwords, etc., in pipelines.", + "RationaleStatement": "Sensitive data in pipeline configuration, such as cloud provider credentials or repository credentials, create vulnerabilities with which malicious actors could steal such information if they gain access to a pipeline. In order to mitigate this, set scanners that will identify and prevent the existence of sensitive data in the pipeline.", + "ImpactStatement": "", + "RemediationProcedure": "For every pipeline that is in use, set scanners that will identify and prevent sensitive data within it.", + "AuditProcedure": "For every pipeline that is in use, verify that scanners are set to identify and prevent the existence of sensitive data within it.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.4.1", + "Description": "Sign all artifacts in all releases with user or organization keys.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.4 Pipeline Integrity", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Sign all artifacts in all releases with user or organization keys.", + "RationaleStatement": "Signing artifacts is used to validate both their integrity and security. Organizations signal that artifacts may be trusted and they themselves produced them by ensuring that every artifact is properly signed. The presence of this signature also makes potentially malicious activity far more difficult.", + "ImpactStatement": "", + "RemediationProcedure": "For every artifact in every release, verify that all are properly signed.", + "AuditProcedure": "Ensure every artifact in every release is signed.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.4.2", + "Description": "External dependencies may be public packages needed in the pipeline, or perhaps the public image being used for the build worker. Lock these external dependencies in every build pipeline.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.4 Pipeline Integrity", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "External dependencies may be public packages needed in the pipeline, or perhaps the public image being used for the build worker. Lock these external dependencies in every build pipeline.", + "RationaleStatement": "External dependencies are sources of code that aren't under organizational control. They might be intentionally or unintentionally infected with malicious code or have known vulnerabilities, which could result in sensitive data exposure, data harvesting, or the erosion of trust in an organization. Locking each external dependency to a specific, safe version gives more control and less chance for risk.", + "ImpactStatement": "", + "RemediationProcedure": "For all external dependencies being used in pipelines, verify they are locked.", + "AuditProcedure": "Ensure every external dependency being used in pipelines is locked.", + "AdditionalInformation": "", + "References": "https://argon.io/blog/pipeline-composition-analysis-how-your-ci-pipeline-presents-new-opportunities-for-attackers/", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.4.3", + "Description": "Validate every dependency of the pipeline before use.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.4 Pipeline Integrity", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Validate every dependency of the pipeline before use.", + "RationaleStatement": "To ensure that a dependency used in a pipeline is trusted and has not been infected by malicious actor (for example, the codecov incident), validate dependencies before using them. This can be accomplished by comparing the checksum of the dependency to its checksum in a trusted source. If a difference arises, this is a sign that an unknown actor has interfered and may have added malevolent code. If this dependency is used, it will infect the environment, which could end in a massive breach and leave the organization exposed to data leaks, etc.", + "ImpactStatement": "", + "RemediationProcedure": "For every dependency used in every pipeline, validate each one.", + "AuditProcedure": "For every dependency used in every pipeline, ensure it has been validated.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.4.4", + "Description": "Verify that the build pipeline creates reproducible artifacts, meaning that an artifact of the build pipeline is the same in every run when given the same input.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.4 Pipeline Integrity", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Verify that the build pipeline creates reproducible artifacts, meaning that an artifact of the build pipeline is the same in every run when given the same input.", + "RationaleStatement": "A reproducible build is a build that produces the same artifact when given the same input data. Ensuring that the build pipeline produces the same artifact when given the same input helps verify that no change has been made to the artifact. This action allows an organization to trust that its artifacts are built only from safe code that has been reviewed and tested and has not been tainted or changed abruptly.", + "ImpactStatement": "", + "RemediationProcedure": "Create build pipelines that produce the same artifact given the same input (for example, artifacts that do not rely on timestamps).", + "AuditProcedure": "Ensure that build pipelines create reproducible artifacts.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.4.5", + "Description": "SBOM (Software Bill of Materials) is a file that specifies each component of software or a build process. Generate an SBOM after each run of a pipeline.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.4 Pipeline Integrity", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "SBOM (Software Bill of Materials) is a file that specifies each component of software or a build process. Generate an SBOM after each run of a pipeline.", + "RationaleStatement": "Generating a Software Bill of Materials after each run of a pipeline will validate the integrity and security of that pipeline. Recording every step or component role in the pipeline ensures that no malicious acts have been committed during the pipeline's run.", + "ImpactStatement": "", + "RemediationProcedure": "For each pipeline, configure it to produce a Software Bill of Materials on every run.", + "AuditProcedure": "For each pipeline, ensure it produces a Software Bill of Materials on every run.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.4.6", + "Description": "SBOM (Software Bill of Materials) is a file that specifies each component of software or a build process. It should be generated after every pipeline run. After it is generated, it must then be signed.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.4 Pipeline Integrity", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "SBOM (Software Bill of Materials) is a file that specifies each component of software or a build process. It should be generated after every pipeline run. After it is generated, it must then be signed.", + "RationaleStatement": "Software Bill of Materials (SBOM) is a file used to validate the integrity and security of a build pipeline. Signing it ensures that no one tampered with the file when it was delivered. Such interference can happen if someone tries to hide unusual activity. Validating the SBOM signature can detect this activity and prevent much greater incident.", + "ImpactStatement": "", + "RemediationProcedure": "For each pipeline, configure it to sign its produced Software Bill of Materials on every run.", + "AuditProcedure": "For each pipeline, ensure it signs the Software Bill of Materials it produces on every run.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.1", + "Description": "Ensure third-party artifacts and open-source libraries in use are trusted and verified.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.1 Third-Party Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure third-party artifacts and open-source libraries in use are trusted and verified.", + "RationaleStatement": "Verify third-party artifacts used in code are trusted and have not been infected by a malicious actor before use. This can be accomplished, for example, by comparing the checksum of the dependency to its checksum in a trusted source. If a difference arises, this may be a sign that someone interfered and added malicious code. If this dependency is used, it will infect the environment and could end in a massive breach, leaving the organization exposed to data leaks and more.", + "ImpactStatement": "", + "RemediationProcedure": "Verify every GitHub action (building block of a GitHub workflow) in use by performing the following: \n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the left sidebar, click **Actions**, then click **General**.\n4. Under \"Policies\", check **Allow OWNER, and select non-OWNER, actions and reusable workflows**, and then **Allow actions created by GitHub**.\n5. Click **Save**.\n\nAlternatively, perform the following for each repository where GitHub actions are used:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the left sidebar, click **Actions**, then click **General**.\n4. Under \"Actions permissions\", check **Allow OWNER, and select non-OWNER, actions and reusable workflows**, and then **Allow actions created by GitHub**.\n5. Click **Save**.", + "AuditProcedure": "For every GitHub action (building block of a GitHub workflow), ensure verification before use by performing the following: \n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the left sidebar, click **Actions**, then click **General**.\n4. Under \"Policies\", ensure **Allow OWNER, and select non-OWNER, actions and reusable workflows**, and **Allow actions created by GitHub** are checked.\n\nAlternatively, perform the following for each repository where GitHub actions are used:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the left sidebar, click **Actions**, then click **General**.\n4. Under \"Actions permissions\", ensure **Allow OWNER, and select non-OWNER, actions and reusable workflows** and **Allow actions created by GitHub** are checked.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.2", + "Description": "A Software Bill Of Materials (SBOM) is a file that specifies each component of software or a build process. Require an SBOM from every third-party provider.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.1 Third-Party Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "A Software Bill Of Materials (SBOM) is a file that specifies each component of software or a build process. Require an SBOM from every third-party provider.", + "RationaleStatement": "A Software Bill of Materials (SBOM) for every third-party artifact helps to ensure an artifact is safe to use and fully compliant. This file lists all important metadata, especially all the dependencies of an artifact, and allows for verification of each dependency. If one of the dependencies/artifacts are attacked or has a new vulnerability (for example, the \"SolarWinds\" or even \"log4j\" attacks), it is easier to detect what has been affected by this incident because dependencies in use are listed in the SBOM file.", + "ImpactStatement": "", + "RemediationProcedure": "For every third-party dependency in use, require a Software Bill of Materials from its supplier.", + "AuditProcedure": "For every third-party dependency in use, ensure it has a Software Bill of Materials.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.3", + "Description": "Require and verify signed metadata of the build process for all dependencies in use.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.1 Third-Party Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Require and verify signed metadata of the build process for all dependencies in use.", + "RationaleStatement": "The metadata of a build process lists every action that took place during an artifact build. It is used to ensure that an artifact has not been compromised during the build, that no malicious code was injected into it, and that no nefarious dependencies were added during the build phase. This creates trust between user and vendor that the software supplied is exactly the software that was promised. Signing this metadata adds a checksum to ensure there have been no revisions since its creation, as this checksum changes when the metadata is altered. Verification of proper metadata signature with Certificate Authority confirms that the signature was produced by a trusted entity.", + "ImpactStatement": "", + "RemediationProcedure": "For each artifact in use, require and verify signed metadata of the build process.", + "AuditProcedure": "For each artifact used, ensure it was supplied with verified and signed metadata of its build process. The signature should be the organizational signature and should be verifiable by common Certificate Authority servers.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.4", + "Description": "Monitor, or ask software suppliers to monitor, dependencies between open-source components in use.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.1 Third-Party Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Monitor, or ask software suppliers to monitor, dependencies between open-source components in use.", + "RationaleStatement": "Monitoring dependencies between open-source components helps to detect if software has fallen victim to attack on a common open-source component. Swift detection can aid in quick application of a fix. It also helps find potential compliance problems with components usage. Some dependencies might not be compatible with the organization's policies, and other dependencies might have a license that is not compatible with how the organization uses this specific dependency. If dependencies are monitored, such situations can be detected and mitigated sooner, potentially deterring malicious attacks.", + "ImpactStatement": "", + "RemediationProcedure": "For each open-source component, monitor its dependencies.", + "AuditProcedure": "For each open-source component, ensure its dependencies are monitored.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.5", + "Description": "Prioritize trusted package registries over others when pulling a package.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.1 Third-Party Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Prioritize trusted package registries over others when pulling a package.", + "RationaleStatement": "When pulling a package by name, the package manager might look for it in several package registries, some of which may be untrusted or badly configured. If the package is pulled from such a registry, there is a higher likelihood that it could prove malicious. In order to avoid this, configure packages to be pulled from trusted package registries.", + "ImpactStatement": "", + "RemediationProcedure": "For each package to be downloaded, configure it to be downloaded from a trusted source.", + "AuditProcedure": "For each package registry in use, ensure it is trusted.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.6", + "Description": "A Software Bill of Materials (SBOM) is a file that specifies each component of software or a build process. When using a dependency, demand its SBOM and ensure it is signed for validation purposes.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.1 Third-Party Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "A Software Bill of Materials (SBOM) is a file that specifies each component of software or a build process. When using a dependency, demand its SBOM and ensure it is signed for validation purposes.", + "RationaleStatement": "A Software Bill of Materials (SBOM) creates trust between its provider and its users by ensuring that the software supplied is the software described, without any potential interference in between. Signing an SBOM creates a checksum for it, which will change if the SBOM's content was changed. With that checksum, a software user can be certain nothing had happened to it during the supply chain, engendering trust in the software. When there is no such trust in the software, the risk surface is increased because one cannot know if the software is potentially vulnerable. Demanding a signed SBOM and validating it decreases that risk.", + "ImpactStatement": "", + "RemediationProcedure": "For every artifact supplied, require and verify a signed Software Bill of Materials from its supplier.", + "AuditProcedure": "For every artifact supplied, ensure it has a validated, signed Software Bill of Materials.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.7", + "Description": "Pin dependencies to a specific version. Avoid using the \"latest\" tag or broad version.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.1 Third-Party Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Pin dependencies to a specific version. Avoid using the \"latest\" tag or broad version.", + "RationaleStatement": "When using a wildcard version of a package, or the \"latest\" tag, the risk of encountering a new, potentially malicious package increases. The \"latest\" tag pulls the last package pushed to the registry. This means that if an attacker pushes a new, malicious package successfully to the registry, the next user who pulls the \"latest\" will pull it and risk attack. This same rule applies to a wildcard version - assuming one is using version v1.*, it will install the latest version of the major version 1, meaning that if an attacker can push a malicious package with that same version, those using it will be subject to possible attack. By using a secure, verified version, use is restricted to this version only and no other may be pulled, decreasing the risk for any malicious package.", + "ImpactStatement": "", + "RemediationProcedure": "For every dependency in use, pin to a specific version.", + "AuditProcedure": "For every dependency in use, ensure it is pinned to a specific version.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.8", + "Description": "Use packages that are more than 60 days old.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.1 Third-Party Packages", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Use packages that are more than 60 days old.", + "RationaleStatement": "Third-party packages are a major risk since an organization cannot control their source code, and there is always the possibility these packages could be malicious. It is therefore good practice to remain cautious with any third-party or open-source package, especially new ones, until they can be verified that they are safe to use. Avoiding a new package allows the organization to fully examine it, its maintainer, and its behavior, and gives enough time to determine whether or not to use it.", + "ImpactStatement": "Developers may not use packages that are less than 60 days old.", + "RemediationProcedure": "If a package used is less than 60 days old, stop using it and find another solution.", + "AuditProcedure": "For every package used, ensure it is more than 60 days old.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.2.1", + "Description": "Enforce a policy for dependency usage across the organization. For example, disallow the use of packages less than 60 days old.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.2 Validate Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Enforce a policy for dependency usage across the organization. For example, disallow the use of packages less than 60 days old.", + "RationaleStatement": "Enforcing a policy for dependency usage in an organization helps to manage dependencies across the organization and ensure that all usage is compliant with security policy. If, for example, the policy limits the package managers that can be used, enforcing it will make sure that every dependency is installed only from these package managers, and limit the risk of installing from any untrusted source.", + "ImpactStatement": "", + "RemediationProcedure": "Enforce policies for dependency usage across the organization.", + "AuditProcedure": "Verify that a policy for dependency usage is enforced across the organization.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.2.2", + "Description": "Automatically scan every package for vulnerabilities.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.2 Validate Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Automatically scan every package for vulnerabilities.", + "RationaleStatement": "Automatic scanning for vulnerabilities detects known vulnerabilities in packages and dependencies in use, allowing faster patching when one is found. Such vulnerabilities can lead to a massive breach if not handled as fast as possible, as attackers will also know about those vulnerabilities and swiftly try to take advantage of them. Scanning packages regularly for vulnerabilities can also verify usage compliance with the organization's security policy.", + "ImpactStatement": "", + "RemediationProcedure": "Set automatic scanning of packages for vulnerabilities.", + "AuditProcedure": "Ensure automatic scanning of packages for vulnerabilities is enabled.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.2.3", + "Description": "A software license is a document that provides legal conditions and guidelines for the use and distribution of software, usually defined by the author. It is recommended to scan for any legal implications automatically.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.2 Validate Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "A software license is a document that provides legal conditions and guidelines for the use and distribution of software, usually defined by the author. It is recommended to scan for any legal implications automatically.", + "RationaleStatement": "When using packages with software licenses, especially commercial ones which tend to be the strictest, it is important to verify that the use of the package meets the conditions of the license. If the use of the package violates the licensing agreement, it exposes the organization to possible lawsuits. Scanning used packages for such license implications leads to faster detection and quicker fixes of such violations, and also reduces the risk for a lawsuit.", + "ImpactStatement": "", + "RemediationProcedure": "Set automatic package scanning for license implications.", + "AuditProcedure": "Ensure license implication rules are configured and are scanned automatically.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.2.4", + "Description": "Scan every package automatically for ownership change.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.2 Validate Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Scan every package automatically for ownership change.", + "RationaleStatement": "A change in package ownership is not a regular action. In some cases it can lead to a massive problem (for example, the \"event-stream\" incident). Open-source contributors are not always trusted, since by its very nature everyone can contribute. This means malicious actors can become contributors as well. Package maintainers might transfer their ownership to someone they do not know if maintaining the package is too much for them, in some cases without the other user's knowledge. This has led to known security breaches in the past. It is best to be aware of such activity as soon as it happens and to carefully examine the situation before continuing using the package in order to determine its safety.", + "ImpactStatement": "", + "RemediationProcedure": "Set automatic scanning of packages for ownership change.", + "AuditProcedure": "Ensure automatic scanning of packages for ownership change is set.", + "AdditionalInformation": "", + "References": "https://blog.npmjs.org/post/182828408610/the-security-risks-of-changing-package-owners.html:https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.1.1", + "Description": "Configure the build pipeline to sign every artifact it produces and verify that each artifact has the appropriate signature.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.1 Verification", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Configure the build pipeline to sign every artifact it produces and verify that each artifact has the appropriate signature.", + "RationaleStatement": "A cryptographic signature can be used to verify artifact authenticity. The signature created with a certain key is unique and not reversible, thus making it unique to the author. This means that an attacker tampering with a signed artifact will be noticed immediately using a simple verification step because the signature will change. Signing artifacts by the build pipeline that produces them ensures the integrity of those artifacts.", + "ImpactStatement": "", + "RemediationProcedure": "Sign every artifact produced with the build pipeline that created it. Configure the build pipeline to sign each artifact.\n\nSteps from GitHub Documentation:\n\nYou can follow the steps below to sign artifacts in GitHub actions. The trick involves loading in your private key into GitHub Actions using the gpg command-line commands.\n\nExport your gpg private key from the system that you have created it.\nFind your key-id (using gpg --list-secret-keys --keyid-format=long)\nExport the gpg secret key to an ASCII file using gpg --export-secret-keys -a > secret.txt\nEdit secret.txt using a plain text editor, and replace all newlines with a literal \"\\n\" until everything is on a single line\nSet up GitHub Actions secrets\nCreate a secret called OSSRH_GPG_SECRET_KEY using the text from your edited secret.txt file (the whole text should be in a single line)\nCreate a secret called OSSRH_GPG_SECRET_KEY_PASSWORD containing the password for your gpg secret key\nCreate a GitHub Actions step to install the gpg secret key\nAdd an action similar to:\n```\n- id: install-secret-key\n name: Install gpg secret key\n run: |\n cat <(echo -e \"${{ secrets.OSSRH_GPG_SECRET_KEY }}\") | gpg --batch --import\n gpg --list-secret-keys --keyid-format LONG\n```\nVerify that the secret key is shown in the GitHub Actions logs\nYou can remove the output from list secret keys if you are confident that this action will work, but it is better to leave it in there\nBring it all together, and create a GitHub Actions step to publish\nAdd an action similar to:\n```\n- id: publish-to-central\n name: Publish to Central Repository\n env:\n MAVEN_USERNAME: ${{ secrets.OSSRH_USERNAME }}\n MAVEN_PASSWORD: ${{ secrets.OSSRH_TOKEN }}\n run: |\n mvn \\\n --no-transfer-progress \\\n --batch-mode \\\n -Dgpg.passphrase=${{ secrets.OSSRH_GPG_SECRET_KEY_PASSWORD }} \\\n clean deploy\n```\nAfter a couple of hours, verify that the artifact got published to The Central Repository", + "AuditProcedure": "Verify that the build pipeline signs every new artifact it produces and all artifacts are signed.\n\nThere are many different signing tools or options each have there own method or commands to verify that the code or package created is signed.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/code-security/supply-chain-security/end-to-end-supply-chain/securing-builds:https://gist.github.com/sualeh/ae78dc16123899d7942bc38baba5203c", + "DefaultValue": "Artifacts are not signed by Default." + } + ] + }, + { + "Id": "4.1.2", + "Description": "Encrypt artifacts before they are distributed and ensure only trusted platforms have decryption capabilities.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.1 Verification", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Encrypt artifacts before they are distributed and ensure only trusted platforms have decryption capabilities.", + "RationaleStatement": "Build artifacts might contain sensitive data such as production configurations. In order to protect them and decrease the risk for breach, it is recommended to encrypt them before delivery. Encryption makes data unreadable, so even if attackers gain access to these artifacts, they won't be able to harvest sensitive data from them without the decryption key.", + "ImpactStatement": "", + "RemediationProcedure": "Encrypt every artifact before distribution.", + "AuditProcedure": "Ensure every artifact is encrypted before it is delivered.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "Artifacts do not get encrypted by default." + } + ] + }, + { + "Id": "4.1.3", + "Description": "Grant decryption capabilities of artifacts only to trusted and authorized platforms.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.1 Verification", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Grant decryption capabilities of artifacts only to trusted and authorized platforms.", + "RationaleStatement": "Build artifacts might contain sensitive data such as production configuration. To protect them and decrease the risk of a breach, it is recommended to encrypt them before delivery. This will make them unreadable for every unauthorized user who doesn't have the decryption key. By implementing this, the decryption capabilities become overly sensitive in order to prevent a data leak or theft. Ensuring that only trusted and authorized platforms can decrypt the organization's packages decreases the possibility for an attacker to gain access to the critical data in artifacts.", + "ImpactStatement": "", + "RemediationProcedure": "Grant decryption capabilities of the organization's artifacts only for trusted and authorized platforms.", + "AuditProcedure": "Ensure only trusted and authorized platforms have decryption capabilities of the organization's artifacts.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.2.1", + "Description": "Software certification is used to verify the safety of certain software usage and to establish trust between the supplier and the consumer. Any artifact can be certified. Limit the authority to certify different artifacts.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.2 Access to Artifacts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Software certification is used to verify the safety of certain software usage and to establish trust between the supplier and the consumer. Any artifact can be certified. Limit the authority to certify different artifacts.", + "RationaleStatement": "Artifact certification is a powerful tool in establishing trust. Clients use a software certificate to verify that the artifact is safe to use according to their security policies. Because of this, certifying artifacts is considered sensitive. If an artifact is for debugging or internal use, or if it were compromised, the organization would not want certification. An attacker gaining access to both certificate authority and the artifact registry might also be able to certify its own artifact and cause a major breach. To prevent these issues, limit which artifacts can be certified by which platform so there will be minimal access to certification.", + "ImpactStatement": "", + "RemediationProcedure": "Limit which artifact can be certified by which authority.", + "AuditProcedure": "Ensure only certain artifacts can be certified by certain parties.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.2.2", + "Description": "Minimize ability to upload artifacts to the lowest number of trusted users possible.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.2 Access to Artifacts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Minimize ability to upload artifacts to the lowest number of trusted users possible.", + "RationaleStatement": "Artifacts might contain sensitive data. Even the simplest mistake can also lead to trust issues with customers and harm the integrity of the product. To decrease these risks, allow only trusted and qualified users to upload new artifacts. Those users are less likely to make mistakes. Having the lowest number of such users possible will also decrease the risk of hacked user accounts, which could lead to a massive breach or artifact compromising.", + "ImpactStatement": "", + "RemediationProcedure": "Allow only trusted and qualified users to upload new artifacts and limit them in number.", + "AuditProcedure": "Ensure only trusted and qualified users can upload new artifacts, and that their number is the lowest possible.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.2.3", + "Description": "Enforce Multi-Factor Authentication (MFA) for user access to the package registry.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.2 Access to Artifacts", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Enforce Multi-Factor Authentication (MFA) for user access to the package registry.", + "RationaleStatement": "By default, every user authenticates to the system by password only. If a user's password is compromised, the user account and all its related packages are in danger of data theft and malicious builds. It is therefore recommended that each user enables Multi-Factor Authentication. This additional step guarantees that the account stays secure even if the user's password is compromised, as it adds another layer of authentication.", + "ImpactStatement": "", + "RemediationProcedure": "For each package registry in use, enforce Multi-Factor Authentication as the only way to authenticate.", + "AuditProcedure": "For each package registry in use, verify that Multi-Factor Authentication is enforced and is the only way to authenticate.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.2.4", + "Description": "Manage users and their access to the package registry with an external authentication server and not with the package registry itself.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.2 Access to Artifacts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Manage users and their access to the package registry with an external authentication server and not with the package registry itself.", + "RationaleStatement": "Some package registries offer a tool for user management, aside from the main Lightweight Directory Access Protocol (LDAP) or Active Directory (AD) server of the organization. That tool usually offers simple authentication and role-based permissions, which might not be granular enough. Having multiple user management tools in the organization could result in confusion and privilege escalation, as there will be more to manage. To avoid a situation where users escalate their privileges because someone missed them, manage user access to the package registry via the main authentication server and not locally on the package registry.", + "ImpactStatement": "", + "RemediationProcedure": "For each package registry, use the main authentication server of the organization for user management and do not manage locally.", + "AuditProcedure": "For each package registry, verify that its user access is not managed locally, but instead with the main authentication server of the organization.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.2.5", + "Description": "For GitHub Private or Internal repositories anonymous access is not available. Verify that all repos that require access controls are Private or Internal.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.2 Access to Artifacts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "For GitHub Private or Internal repositories anonymous access is not available. Verify that all repos that require access controls are Private or Internal.", + "RationaleStatement": "Disable the option to view artifacts as an anonymous user in order to protect private artifacts from being exposed.", + "ImpactStatement": "Only logged and authorized users will be able to access artifacts.", + "RemediationProcedure": "Changing a repository's visibility\n\n1. On your GitHub Enterprise Server instance, navigate to the main page of the repository.\n1. Under your repository name, click Settings\n1. Under \"Danger Zone\", to the right of to \"Change repository visibility\", click Change visibility.\n1. Select a visibility.\n\n1. Choose \n - Mark private\n - Make Internal\n\n6. To verify that you're changing the correct repository's visibility, type the name of the repository you want to change the visibility of.", + "AuditProcedure": "To Audit the existing settings:\n\n1. On your GitHub Enterprise Server instance, navigate to the main page of the repository.\n1. Under your repository name, click Settings\n3. Under \"Danger Zone\", to the right of to \"Change repository visibility\", click Change visibility.\n\nReview the current selection.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/enterprise-server@3.3/repositories/managing-your-repositorys-settings-and-features/managing-repository-settings/setting-repository-visibility", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.2.6", + "Description": "Ensure the package registry has a minimum number of administrators.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.2 Access to Artifacts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure the package registry has a minimum number of administrators.", + "RationaleStatement": "Package registry admins have the ability to add/remove users, repositories, packages. Due to the permissive access granted to an admin, it is highly recommended to keep the number of administrator accounts as minimal as possible.", + "ImpactStatement": "Administrator privileges are required to provide and maintain a secure and stable platform but allowing extraneous administrator accounts can create a vulnerability.", + "RemediationProcedure": "Set the minimum number of administrators in your package registry.\n\nTo accomplish this:\n\nFor each repository that you administer on GitHub, you can see an overview of every team or person with access to the repository. From the overview, choose Manage access and provide access to the appropriate people or teams.", + "AuditProcedure": "Verify that your package registry has only the minimum number of administrators.\n\nFor each repository that you administer on GitHub, you can see an overview of every team or person with access to the repository. From the overview, you can also invite new teams or people, change each team or person's role for the repository, or remove access to the repository.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/managing-repository-settings/managing-teams-and-people-with-access-to-your-repository", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.3.1", + "Description": "Validate artifact signatures before uploading to the package registry.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.3 Package Registries", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Validate artifact signatures before uploading to the package registry.", + "RationaleStatement": "Cryptographic signature is a tool to verify artifact authenticity. Every artifact is supposed to be signed by its creator in order to confirm that it was not compromised before reaching the client. Validating an artifact signature before delivering it is another level of protection which ensures the signature has not been changed, meaning no one tried or succeeded in tampering with the artifact. This creates trust between the supplier and the client.", + "ImpactStatement": "", + "RemediationProcedure": "Validate every artifact with its signature before uploading it to the package registry. It is recommended to do so automatically.", + "AuditProcedure": "Ensure every artifact in the package registry has been validated with its signature.\n\n1. On GitHub, navigate to a pull request\n2. On the pull request, click <> Commits and view the detailed information regarding the signature.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification:https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits", + "DefaultValue": "Artifacts are not scanned by default." + } + ] + }, + { + "Id": "4.3.2", + "Description": "Validate the signature of all versions of an existing artifact.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.3 Package Registries", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Validate the signature of all versions of an existing artifact.", + "RationaleStatement": "In order to be certain a version of an existing and trusted artifact is not malicious or delivered by someone looking to interfere with the supply chain, it is a good practice to validate the signatures of each version. Doing so decreases the risk of using a compromised artifact, which might lead to a breach.", + "ImpactStatement": "", + "RemediationProcedure": "For each artifact, sign and validate each version before uploading or using the artifact.", + "AuditProcedure": "For each artifact, ensure that all of its versions are signed and validated before it is uploaded or used.\n\nEnsure every artifact in the package registry has been validated with its signature.\n\nOn GitHub, navigate to a pull request\nOn the pull request, click <> Commits and view the detailed information regarding the signature.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.3.3", + "Description": "Audit changes of the package registry configuration.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.3 Package Registries", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Audit changes of the package registry configuration.", + "RationaleStatement": "The package registry is a crucial component in the software supply chain. It stores artifacts with potentially sensitive data that will eventually be deployed and used in production. Every change made to the package registry configuration must be examined carefully to ensure no exposure of the registry's sensitive data. This examination also ensures no malicious actors have performed modifications to a stored artifact. Auditing the configuration and its changes helps in decreasing such risks.", + "ImpactStatement": "", + "RemediationProcedure": "Audit the changes to the package registry configuration.", + "AuditProcedure": "Verify that all changes to the packages registry configuration are audited.\n\nSearch the audit log with\n\nrepo category actions", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/reviewing-the-audit-log-for-your-organization", + "DefaultValue": "GitHub audits this by default." + } + ] + }, + { + "Id": "4.3.4", + "Description": "Use secured webhooks to reduce the possibility of malicious payloads.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.3 Package Registries", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Use secured webhooks to reduce the possibility of malicious payloads.", + "RationaleStatement": "Webhooks are used for triggering an HTTP request based on an action made in the platform. Typically, package registries feature webhooks when a package receives an update. Since webhooks are an HTTP POST request, they can be malformed if not secured over SSL. To prevent a potential hack and compromise of the webhook or to the registry or web server excepting the request, use only secured webhooks.", + "ImpactStatement": "Reduces the payloads that the web hook can listen for and receive.", + "RemediationProcedure": "For each webhook in use, change it to secured (over HTTPS).", + "AuditProcedure": "", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.4.1", + "Description": "When delivering artifacts, ensure they have information about their origin. This may be done by providing a Software Bill of Manufacture (SBOM) or some metadata files.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.4 Origin Traceability", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "When delivering artifacts, ensure they have information about their origin. This may be done by providing a Software Bill of Manufacture (SBOM) or some metadata files.", + "RationaleStatement": "Information about artifact origin can be used for verification purposes. Having this kind of information allows the user to decide if the organization supplying the artifact is trusted. In a case of potential vulnerability or version update, this can be used to verify that the organization issuing it is the actual origin and not someone else. If users need to report problems with the artifact, they will have an address to contact as well.", + "ImpactStatement": "", + "RemediationProcedure": "For each artifact supplied, supply information about its origin. For each artifact in use, ask for information about its origin.", + "AuditProcedure": "For each artifact, ensure it has information about its origin.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.1", + "Description": "Deployment configurations are often stored in a version control system. Separate deployment configuration files from source code repositories.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.1 Deployment Configuration", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Deployment configurations are often stored in a version control system. Separate deployment configuration files from source code repositories.", + "RationaleStatement": "Deployment configuration manifests are often stored in version control systems. Storing them in dedicated repositories, separately from source code repositories, has several benefits. First, it adds order to both maintenance and version control history. This makes it easier to track code or manifest changes, as well as spot any malicious code or misconfigurations. Second, it helps achieve the Least Privilege principle. Because access can be configured differently for each repository, fewer users will have access to this configuration, which is typically sensitive.", + "ImpactStatement": "", + "RemediationProcedure": "Store each deployment configuration file in a dedicated repository separately from source code.", + "AuditProcedure": "Ensure each deployment configuration file is stored separately from source code.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.2", + "Description": "Audit and track changes made in deployment configuration.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.1 Deployment Configuration", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Audit and track changes made in deployment configuration.", + "RationaleStatement": "Deployment configuration is sensitive in nature. The tiniest mistake can lead to downtime or bugs in production, which consequently may have a direct effect on both product integrity and customer trust. Misconfigurations might also be used by malicious actors to attack the production platform. Because of this, every change in the configuration needs a review and possible \"revert\" in case of a mistake or malicious change. Auditing every change and tracking them helps detect and fix such incidents more quickly.", + "ImpactStatement": "", + "RemediationProcedure": "For each deployment configuration, track and audit changes made to it.", + "AuditProcedure": "For each deployment configuration, ensure changes made to it are audited and tracked.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.3", + "Description": "Detect and prevent sensitive data – such as confidential ID numbers, passwords, etc. – in deployment configurations.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.1 Deployment Configuration", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Detect and prevent sensitive data – such as confidential ID numbers, passwords, etc. – in deployment configurations.", + "RationaleStatement": "Sensitive data in deployment configurations might create a major incident if an attacker gains access to it, as this can cause data loss and theft. It is important to keep sensitive data safe and to not expose it in the configuration. In order to prevent a possible exposure, set scanners that will identify and prevent such data in deployment configurations.", + "ImpactStatement": "", + "RemediationProcedure": "For each deployment configuration file, set scanners to identify and prevent sensitive data within it.", + "AuditProcedure": "For each deployment configuration file, verify that scanners are set to identify and prevent the existence of sensitive data within it.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.4", + "Description": "Restrict access to the deployment configuration to trusted and qualified users only.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.1 Deployment Configuration", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Restrict access to the deployment configuration to trusted and qualified users only.", + "RationaleStatement": "Deployment configurations are sensitive in nature. The tiniest mistake can lead to downtime or bugs in production, which can have a direct effect on the product's integrity and customer trust. Misconfigurations might also be used by malicious actors to attack the production platform. To avoid such harm as much as possible, ensure only trusted and qualified users have access to such configurations. This will also reduce the number of accounts that might affect the environment in case of an attack.", + "ImpactStatement": "Reducing the number of users who have access to the deployment configuration means those users would lose their ability to make direct changes to that configuration.", + "RemediationProcedure": "Restrict access to the deployment configuration to trusted and qualified users.", + "AuditProcedure": "Verify each deployment configuration is accessible only to known and authorized users.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.5", + "Description": "Detect and prevent misconfigurations or insecure instructions in Infrastructure as Code (IaC) files, such as Terraform files.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.1 Deployment Configuration", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Detect and prevent misconfigurations or insecure instructions in Infrastructure as Code (IaC) files, such as Terraform files.", + "RationaleStatement": "Infrastructure as Code (IaC) files are used for production environment and application deployment. These are sensitive parts of the software supply chain because they are always in touch with customers, and thus might affect their opinion of or trust in the product. Attackers often target these environments. Detecting and fixing misconfigurations and/or insecure instructions in IaC files decreases the risk for data leak or data theft. It is important to secure IaC instructions in order to prevent further problems of deployment, exposed assets, or improper configurations, which might ultimately lead to easier ways to attack and steal organization data.", + "ImpactStatement": "", + "RemediationProcedure": "For every Infrastructure as Code (IaC) instructions file, set scanners to identify and prevent misconfigurations and insecure instructions.", + "AuditProcedure": "For every Infrastructure as Code (IaC) instructions file, verify that scanners are set to identify and prevent misconfigurations and insecure instructions.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.6", + "Description": "Verify the deployment configuration manifests.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.1 Deployment Configuration", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Verify the deployment configuration manifests.", + "RationaleStatement": "To ensure that the configuration manifests used are trusted and have not been infected by malicious actors before arriving at the platform, it is important to verify the manifests. This may be done by comparing the checksum of the manifest file to its checksum in a trusted source. If a difference arises, this is a sign that an unknown actor has interfered and may have added malicious instructions. If this manifest is used, it might harm the environment and application deployment, which could end in a massive breach and leave the organization exposed to data leaks, etc.", + "ImpactStatement": "", + "RemediationProcedure": "Verify each deployment configuration manifest in use.", + "AuditProcedure": "For each deployment configuration manifest in use, ensure it has been verified.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.7", + "Description": "Deployment configuration is often stored in a version control system and is pulled from there. Pin the configuration used to a specific, verified version or commit Secure Hash Algorithm (SHA). Avoid referring configuration without its version tag specified.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.1 Deployment Configuration", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Deployment configuration is often stored in a version control system and is pulled from there. Pin the configuration used to a specific, verified version or commit Secure Hash Algorithm (SHA). Avoid referring configuration without its version tag specified.", + "RationaleStatement": "Deployment configuration manifests are often stored in version control systems and pulled from there either by automation platforms, for example Ansible, or GitOps platforms, such as ArgoCD. When a manifest is pulled from a version control system without tag or commit Secure Hash Algorithm (SHA) specified, it is pulled from the HEAD revision, which is equal to the 'latest' tag, and pulls the last change made. This increases the risk of encountering a new, potentially malicious configuration. If an attacker pushes malicious configuration to the version control system, the next user who pulls the HEAD revision will pull it and risk attack. To avoid that risk, use a version tag of verified version or a commit SHA of a trusted commit, which will ensure this is the only version pulled.", + "ImpactStatement": "Changes in deployment configuration will not be pulled unless their version tag or commit Secure Hash Algorithm (SHA) is specified. This might slow down the deployment process.", + "RemediationProcedure": "For every deployment configuration manifest in use, pin to a specific version or commit Secure Hash Algorithm (SHA).", + "AuditProcedure": "For every deployment configuration manifest in use, ensure it is pinned to a specific version or commit Secure Hash Algorithm (SHA).", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.2.1", + "Description": "Automate deployments of production environment and application.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.2 Deployment Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Automate deployments of production environment and application.", + "RationaleStatement": "Automating the deployments of both production environment and applications reduces the risk for human mistakes — such as a wrong configuration or exposure of sensitive data — because it requires less human interaction or intervention. It also eases redeployment of the environment. It is best to automate with Infrastructure as Code (IaC) because it offers more control over changes made to the environment creation configuration and stores to a version control platform.", + "ImpactStatement": "", + "RemediationProcedure": "Automate each deployment process of the production environment and application.", + "AuditProcedure": "For each deployment process, ensure it is automated.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.2.2", + "Description": "Verify that the deployment environment – the orchestrator and the production environment where the application is deployed – is reproducible. This means that the environment stays the same in each deployment if the configuration has not changed.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.2 Deployment Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Verify that the deployment environment – the orchestrator and the production environment where the application is deployed – is reproducible. This means that the environment stays the same in each deployment if the configuration has not changed.", + "RationaleStatement": "A reproducible build is a build that produces the same artifact when given the same input data, and in this case the same environment. Ensuring that the same environment is produced when given the same input helps verify that no change has been made to it. This action allows an organization to trust that its deployment environment is built only from safe code and configuration that has been reviewed and tested and has not been tainted or changed abruptly.", + "ImpactStatement": "", + "RemediationProcedure": "Adjust the process that deploys the deployment/production environment to build the same environment each time when the configuration has not changed.", + "AuditProcedure": "Verify that the deployment/production environment is reproducible.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.2.3", + "Description": "Restrict access to the production environment to a few trusted and qualified users only.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.2 Deployment Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Restrict access to the production environment to a few trusted and qualified users only.", + "RationaleStatement": "The production environment is an extremely sensitive one. It directly affects the customer experience and trust in a product, which has serious effects on the organization itself. Because of this sensitive nature, it is important to restrict access to the production environment to only a few trusted and qualified users. This will reduce the risk of mistakes such as exposure of secrets or misconfiguration. This restriction also reduces the number of accounts that are vulnerable to hijacking in order to potentially harm the production environment.", + "ImpactStatement": "Reducing the number of users who have access to the production environment means those users would lose their ability to make direct changes to that environment.", + "RemediationProcedure": "Restrict access to the production environment to trusted and qualified users.", + "AuditProcedure": "Verify that the production environment is accessible only to trusted and qualified users.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.2.4", + "Description": "Do not use default passwords of deployment tools and components.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.2 Deployment Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not use default passwords of deployment tools and components.", + "RationaleStatement": "Many deployment tools and components are provided with default passwords for the first login. This password is intended to be used only on the first login and should be changed immediately after. Using the default password substantially increases the attack risk. It is very important to ensure that default passwords are not used in deployment tools and components.", + "ImpactStatement": "", + "RemediationProcedure": "For each deployment tool, change the password.", + "AuditProcedure": "For each deployment tool, ensure the password is not the default one.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + } + ] +} diff --git a/prowler/compliance/kubernetes/cis_1.10_kubernetes.json b/prowler/compliance/kubernetes/cis_1.10_kubernetes.json index a049efc040..ed0e90d88f 100644 --- a/prowler/compliance/kubernetes/cis_1.10_kubernetes.json +++ b/prowler/compliance/kubernetes/cis_1.10_kubernetes.json @@ -820,6 +820,14 @@ "Checks": [ "apiserver_audit_log_maxage_set" ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 30 + } + ], "Attributes": [ { "Section": "1 Control Plane Components", @@ -858,6 +866,14 @@ "DefaultValue": "By default, auditing is not enabled.", "References": "https://kubernetes.io/docs/admin/kube-apiserver/:https://kubernetes.io/docs/concepts/cluster-administration/audit/:https://github.com/kubernetes/features/issues/22" } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxbackup_set", + "ConfigKey": "audit_log_maxbackup", + "Operator": "gte", + "Value": 10 + } ] }, { @@ -881,6 +897,14 @@ "DefaultValue": "By default, auditing is not enabled.", "References": "https://kubernetes.io/docs/admin/kube-apiserver/:https://kubernetes.io/docs/concepts/cluster-administration/audit/:https://github.com/kubernetes/features/issues/22" } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxsize_set", + "ConfigKey": "audit_log_maxsize", + "Operator": "gte", + "Value": 100 + } ] }, { @@ -1109,6 +1133,18 @@ "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers", "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/ssllabs/research/wiki/SSL-and-TLS-Deployment-Best-Practices#23-use-secure-cipher-suites" } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_strong_ciphers_only", + "ConfigKey": "apiserver_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256" + ] + } ] }, { @@ -2069,6 +2105,23 @@ "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers", "References": "" } + ], + "ConfigRequirements": [ + { + "Check": "kubelet_strong_ciphers_only", + "ConfigKey": "kubelet_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_128_GCM_SHA256" + ] + } ] }, { diff --git a/prowler/compliance/kubernetes/cis_1.11_kubernetes.json b/prowler/compliance/kubernetes/cis_1.11_kubernetes.json index 6a19ea4161..25d725683f 100644 --- a/prowler/compliance/kubernetes/cis_1.11_kubernetes.json +++ b/prowler/compliance/kubernetes/cis_1.11_kubernetes.json @@ -820,6 +820,14 @@ "Checks": [ "apiserver_audit_log_maxage_set" ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 30 + } + ], "Attributes": [ { "Section": "1 Control Plane Components", @@ -858,6 +866,14 @@ "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/:https://github.com/kubernetes/enhancements/issues/22", "DefaultValue": "By default, auditing is not enabled." } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxbackup_set", + "ConfigKey": "audit_log_maxbackup", + "Operator": "gte", + "Value": 10 + } ] }, { @@ -881,6 +897,14 @@ "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/:https://github.com/kubernetes/enhancements/issues/22", "DefaultValue": "By default, auditing is not enabled." } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxsize_set", + "ConfigKey": "audit_log_maxsize", + "Operator": "gte", + "Value": 100 + } ] }, { @@ -1109,6 +1133,18 @@ "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/ssllabs/research/wiki/SSL-and-TLS-Deployment-Best-Practices#23-use-secure-cipher-suites", "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers" } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_strong_ciphers_only", + "ConfigKey": "apiserver_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256" + ] + } ] }, { @@ -2112,6 +2148,23 @@ "References": "", "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers" } + ], + "ConfigRequirements": [ + { + "Check": "kubelet_strong_ciphers_only", + "ConfigKey": "kubelet_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_128_GCM_SHA256" + ] + } ] }, { diff --git a/prowler/compliance/kubernetes/cis_1.12_kubernetes.json b/prowler/compliance/kubernetes/cis_1.12_kubernetes.json index 1ba1e55a88..ded2d6944a 100644 --- a/prowler/compliance/kubernetes/cis_1.12_kubernetes.json +++ b/prowler/compliance/kubernetes/cis_1.12_kubernetes.json @@ -820,6 +820,14 @@ "Checks": [ "apiserver_audit_log_maxage_set" ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 30 + } + ], "Attributes": [ { "Section": "1 Control Plane Components", @@ -858,6 +866,14 @@ "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/:https://github.com/kubernetes/enhancements/issues/22", "DefaultValue": "By default, auditing is not enabled." } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxbackup_set", + "ConfigKey": "audit_log_maxbackup", + "Operator": "gte", + "Value": 10 + } ] }, { @@ -881,6 +897,14 @@ "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/:https://github.com/kubernetes/enhancements/issues/22", "DefaultValue": "By default, auditing is not enabled." } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxsize_set", + "ConfigKey": "audit_log_maxsize", + "Operator": "gte", + "Value": 100 + } ] }, { @@ -1109,6 +1133,18 @@ "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/ssllabs/research/wiki/SSL-and-TLS-Deployment-Best-Practices#23-use-secure-cipher-suites", "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers" } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_strong_ciphers_only", + "ConfigKey": "apiserver_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256" + ] + } ] }, { @@ -2090,6 +2126,23 @@ "References": "", "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers" } + ], + "ConfigRequirements": [ + { + "Check": "kubelet_strong_ciphers_only", + "ConfigKey": "kubelet_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_128_GCM_SHA256" + ] + } ] }, { diff --git a/prowler/compliance/kubernetes/cis_1.8_kubernetes.json b/prowler/compliance/kubernetes/cis_1.8_kubernetes.json index 24a5733a05..a09d753f58 100644 --- a/prowler/compliance/kubernetes/cis_1.8_kubernetes.json +++ b/prowler/compliance/kubernetes/cis_1.8_kubernetes.json @@ -843,6 +843,14 @@ "Checks": [ "apiserver_audit_log_maxage_set" ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 30 + } + ], "Attributes": [ { "Section": "1 Control Plane Components", @@ -881,6 +889,14 @@ "References": "https://kubernetes.io/docs/admin/kube-apiserver/:https://kubernetes.io/docs/concepts/cluster-administration/audit/:https://github.com/kubernetes/features/issues/22", "DefaultValue": "By default, auditing is not enabled." } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxbackup_set", + "ConfigKey": "audit_log_maxbackup", + "Operator": "gte", + "Value": 10 + } ] }, { @@ -904,6 +920,14 @@ "References": "https://kubernetes.io/docs/admin/kube-apiserver/:https://kubernetes.io/docs/concepts/cluster-administration/audit/:https://github.com/kubernetes/features/issues/22", "DefaultValue": "By default, auditing is not enabled." } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxsize_set", + "ConfigKey": "audit_log_maxsize", + "Operator": "gte", + "Value": 100 + } ] }, { @@ -1132,6 +1156,18 @@ "References": "https://github.com/ssllabs/research/wiki/SSL-and-TLS-Deployment-Best-Practices#23-use-secure-cipher-suites", "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers" } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_strong_ciphers_only", + "ConfigKey": "apiserver_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256" + ] + } ] }, { @@ -2092,6 +2128,23 @@ "References": "", "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers" } + ], + "ConfigRequirements": [ + { + "Check": "kubelet_strong_ciphers_only", + "ConfigKey": "kubelet_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_128_GCM_SHA256" + ] + } ] }, { diff --git a/prowler/compliance/kubernetes/cis_2.0.1_kubernetes.json b/prowler/compliance/kubernetes/cis_2.0.1_kubernetes.json new file mode 100644 index 0000000000..2b9e695dd2 --- /dev/null +++ b/prowler/compliance/kubernetes/cis_2.0.1_kubernetes.json @@ -0,0 +1,2968 @@ +{ + "Framework": "CIS", + "Name": "CIS Kubernetes Benchmark v2.0.1", + "Version": "2.0.1", + "Provider": "Kubernetes", + "Description": "This CIS Kubernetes Benchmark provides prescriptive guidance for establishing a secure configuration posture for Kubernetes v1.34 - v1.35", + "Requirements": [ + { + "Id": "1.1.1", + "Description": "Ensure that the API server pod specification file permissions are set to 600 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the API server pod specification file has permissions of `600` or more restrictive.", + "RationaleStatement": "The API server pod specification file controls various parameters that set the behavior of the API server. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-apiserver.yaml ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-apiserver.yaml ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/", + "DefaultValue": "By default, the `kube-apiserver.yaml` file has permissions of `640`." + } + ] + }, + { + "Id": "1.1.2", + "Description": "Ensure that the API server pod specification file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the API server pod specification file ownership is set to `root:root`.", + "RationaleStatement": "The API server pod specification file controls various parameters that set the behavior of the API server. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chown root:root /etc/kubernetes/manifests/kube-apiserver.yaml ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %U:%G /etc/kubernetes/manifests/kube-apiserver.yaml ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/", + "DefaultValue": "By default, the `kube-apiserver.yaml` file ownership is set to `root:root`." + } + ] + }, + { + "Id": "1.1.3", + "Description": "Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.", + "RationaleStatement": "The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/", + "DefaultValue": "By default, the `kube-controller-manager.yaml` file has permissions of `640`." + } + ] + }, + { + "Id": "1.1.4", + "Description": "Ensure that the controller manager pod specification file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the controller manager pod specification file ownership is set to `root:root`.", + "RationaleStatement": "The controller manager pod specification file controls various parameters that set the behavior of various components of the master node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chown root:root /etc/kubernetes/manifests/kube-controller-manager.yaml ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %U:%G /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-controller-manager", + "DefaultValue": "By default, `kube-controller-manager.yaml` file ownership is set to `root:root`." + } + ] + }, + { + "Id": "1.1.5", + "Description": "Ensure that the scheduler pod specification file permissions are set to 600 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the scheduler pod specification file has permissions of `600` or more restrictive.", + "RationaleStatement": "The scheduler pod specification file controls various parameters that set the behavior of the Scheduler service in the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-scheduler.yaml ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-scheduler.yaml ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-scheduler/", + "DefaultValue": "By default, `kube-scheduler.yaml` file has permissions of `640`." + } + ] + }, + { + "Id": "1.1.6", + "Description": "Ensure that the scheduler pod specification file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the scheduler pod specification file ownership is set to `root:root`.", + "RationaleStatement": "The scheduler pod specification file controls various parameters that set the behavior of the `kube-scheduler` service in the master node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chown root:root /etc/kubernetes/manifests/kube-scheduler.yaml ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %U:%G /etc/kubernetes/manifests/kube-scheduler.yaml ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-scheduler/", + "DefaultValue": "By default, `kube-scheduler.yaml` file ownership is set to `root:root`." + } + ] + }, + { + "Id": "1.1.7", + "Description": "Ensure that the etcd pod specification file permissions are set to 600 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `/etc/kubernetes/manifests/etcd.yaml` file has permissions of `600` or more restrictive.", + "RationaleStatement": "The etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` controls various parameters that set the behavior of the `etcd` service in the master node. etcd is a highly-available key-value store which Kubernetes uses for persistent storage of all of its REST API object. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/etcd.yaml ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/etcd.yaml ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd:https://kubernetes.io/docs/admin/etcd/", + "DefaultValue": "By default, `/etc/kubernetes/manifests/etcd.yaml` file has permissions of `640`." + } + ] + }, + { + "Id": "1.1.8", + "Description": "Ensure that the etcd pod specification file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `/etc/kubernetes/manifests/etcd.yaml` file ownership is set to `root:root`.", + "RationaleStatement": "The etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` controls various parameters that set the behavior of the `etcd` service in the master node. etcd is a highly-available key-value store which Kubernetes uses for persistent storage of all of its REST API object. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chown root:root /etc/kubernetes/manifests/etcd.yaml ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %U:%G /etc/kubernetes/manifests/etcd.yaml ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd:https://kubernetes.io/docs/admin/etcd/", + "DefaultValue": "By default, `/etc/kubernetes/manifests/etcd.yaml` file ownership is set to `root:root`." + } + ] + }, + { + "Id": "1.1.9", + "Description": "Ensure that the Container Network Interface file permissions are set to 600 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that the Container Network Interface files have permissions of `600` or more restrictive.", + "RationaleStatement": "Container Network Interface provides various networking options for overlay networking. You should consult their documentation and restrict their respective file permissions to maintain the integrity of those files. Those files should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/cluster-administration/networking/", + "DefaultValue": "NA" + } + ] + }, + { + "Id": "1.1.10", + "Description": "Ensure that the Container Network Interface file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that the Container Network Interface files have ownership set to `root:root`.", + "RationaleStatement": "Container Network Interface provides various networking options for overlay networking. You should consult their documentation and restrict their respective file permissions to maintain the integrity of those files. Those files should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chown root:root ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %U:%G ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/cluster-administration/networking/", + "DefaultValue": "NA" + } + ] + }, + { + "Id": "1.1.11", + "Description": "Ensure that the etcd data directory permissions are set to 700 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the etcd data directory has permissions of `700` or more restrictive.", + "RationaleStatement": "etcd is a highly-available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. This data directory should be protected from any unauthorized reads or writes. It should not be readable or writable by any group members or the world.", + "ImpactStatement": "None", + "RemediationProcedure": "On the etcd server node, get the etcd data directory, passed as an argument `--data-dir`, from the below command: ``` ps -ef | grep etcd ``` Run the below command (based on the etcd data directory found above). For example, ``` chmod 700 /var/lib/etcd ```", + "AuditProcedure": "On the etcd server node, get the etcd data directory, passed as an argument `--data-dir`, from the below command: ``` ps -ef | grep etcd ``` Run the below command (based on the etcd data directory found above). For example, ``` stat -c %a /var/lib/etcd ``` Verify that the permissions are `700` or more restrictive.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd/docs/latest/op-guide/configuration.html#data-dir:https://kubernetes.io/docs/admin/etcd/", + "DefaultValue": "By default, etcd data directory has permissions of `755`." + } + ] + }, + { + "Id": "1.1.12", + "Description": "Ensure that the etcd data directory ownership is set to etcd:etcd", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the etcd data directory ownership is set to `etcd:etcd`.", + "RationaleStatement": "etcd is a highly-available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. This data directory should be protected from any unauthorized reads or writes. It should be owned by `etcd:etcd`.", + "ImpactStatement": "None", + "RemediationProcedure": "On the etcd server node, get the etcd data directory, passed as an argument `--data-dir`, from the below command: ``` ps -ef | grep etcd ``` Run the below command (based on the etcd data directory found above). For example, ``` chown etcd:etcd /var/lib/etcd ```", + "AuditProcedure": "On the etcd server node, get the etcd data directory, passed as an argument `--data-dir`, from the below command: ``` ps -ef | grep etcd ``` Run the below command (based on the etcd data directory found above). For example, ``` stat -c %U:%G /var/lib/etcd ``` Verify that the ownership is set to `etcd:etcd`.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd/docs/latest/op-guide/configuration.html#data-dir:https://kubernetes.io/docs/admin/etcd/", + "DefaultValue": "By default, etcd data directory ownership is set to `etcd:etcd`." + } + ] + }, + { + "Id": "1.1.13", + "Description": "Ensure that the default administrative credential file permissions are set to 600", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `admin.conf` file (and `super-admin.conf` file, where it exists) have permissions of `600`.", + "RationaleStatement": "As part of initial cluster setup, default kubeconfig files are created to be used by the administrator of the cluster. These files contain private keys and certificates which allow for privileged access to the cluster. You should restrict their file permissions to maintain the integrity and confidentiality of the file(s). The file(s) should be readable and writable by only the administrators on the system.", + "ImpactStatement": "None.", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/admin.conf ``` On Kubernetes 1.29+ the `super-admin.conf` file should also be modified, if present. For example, ``` chmod 600 /etc/kubernetes/super-admin.conf ```", + "AuditProcedure": "Run the following command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/admin.conf ``` On Kubernetes version 1.29 and higher run the following command as well :- ``` stat -c %a /etc/kubernetes/super-admin.conf ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/setup/independent/create-cluster-kubeadm/:https://raesene.github.io/blog/2024/01/06/when-is-admin-not-admin/", + "DefaultValue": "By default, admin.conf and super-admin.conf have permissions of `600`." + } + ] + }, + { + "Id": "1.1.14", + "Description": "Ensure that the default administrative credential file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `admin.conf` (and `super-admin.conf` file, where it exists) file ownership is set to `root:root`.", + "RationaleStatement": "As part of initial cluster setup, default kubeconfig files are created to be used by the administrator of the cluster. These files contain private keys and certificates which allow for privileged access to the cluster. You should set their file ownership to maintain the integrity and confidentiality of the file. The file(s) should be owned by `root:root`.", + "ImpactStatement": "None.", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chown root:root /etc/kubernetes/admin.conf ``` On Kubernetes 1.29+ the super-admin.conf file should also be modified, if present. For example, ``` chown root:root /etc/kubernetes/super-admin.conf ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %U:%G /etc/kubernetes/admin.conf ``` On Kubernetes version 1.29 and higher run the following command as well :- ``` stat -c %U:%G /etc/kubernetes/super-admin.conf ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubeadm/:https://raesene.github.io/blog/2024/01/06/when-is-admin-not-admin/", + "DefaultValue": "By default, `admin.conf` and `super-admin.conf` file ownership is set to `root:root`." + } + ] + }, + { + "Id": "1.1.15", + "Description": "Ensure that the scheduler.conf file permissions are set to 600 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `scheduler.conf` file has permissions of `600` or more restrictive.", + "RationaleStatement": "The `scheduler.conf` file is the kubeconfig file for the Scheduler. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/scheduler.conf ```", + "AuditProcedure": "Run the following command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/scheduler.conf ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/setup/independent/create-cluster-kubeadm/", + "DefaultValue": "By default, `scheduler.conf` has permissions of `640`." + } + ] + }, + { + "Id": "1.1.16", + "Description": "Ensure that the scheduler.conf file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `scheduler.conf` file ownership is set to `root:root`.", + "RationaleStatement": "The `scheduler.conf` file is the kubeconfig file for the Scheduler. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chown root:root /etc/kubernetes/scheduler.conf ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %U:%G /etc/kubernetes/scheduler.conf ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubeadm/", + "DefaultValue": "By default, `scheduler.conf` file ownership is set to `root:root`." + } + ] + }, + { + "Id": "1.1.17", + "Description": "Ensure that the controller-manager.conf file permissions are set to 600 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `controller-manager.conf` file has permissions of 600 or more restrictive.", + "RationaleStatement": "The `controller-manager.conf` file is the kubeconfig file for the Controller Manager. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/controller-manager.conf ```", + "AuditProcedure": "Run the following command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/controller-manager.conf ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-controller-manager/", + "DefaultValue": "By default, `controller-manager.conf` has permissions of `640`." + } + ] + }, + { + "Id": "1.1.18", + "Description": "Ensure that the controller-manager.conf file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `controller-manager.conf` file ownership is set to `root:root`.", + "RationaleStatement": "The `controller-manager.conf` file is the kubeconfig file for the Controller Manager. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chown root:root /etc/kubernetes/controller-manager.conf ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %U:%G /etc/kubernetes/controller-manager.conf ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-controller-manager/", + "DefaultValue": "By default, `controller-manager.conf` file ownership is set to `root:root`." + } + ] + }, + { + "Id": "1.1.19", + "Description": "Ensure that the Kubernetes PKI directory and file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the Kubernetes PKI directory and file ownership is set to `root:root`.", + "RationaleStatement": "Kubernetes makes use of a number of certificates as part of its operation. You should set the ownership of the directory containing the PKI information and all files in that directory to maintain their integrity. The directory and files should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chown -R root:root /etc/kubernetes/pki/ ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` ls -laR /etc/kubernetes/pki/ ``` Verify that the ownership of all files and directories in this hierarchy is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/", + "DefaultValue": "By default, the /etc/kubernetes/pki/ directory and all of the files and directories contained within it, are set to be owned by the root user." + } + ] + }, + { + "Id": "1.1.20", + "Description": "Ensure that the Kubernetes PKI certificate file permissions are set to 644 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that Kubernetes PKI certificate files have permissions of `644` or more restrictive.", + "RationaleStatement": "Kubernetes makes use of a number of certificate files as part of the operation of its components. The permissions on these files should be set to `644` or more restrictive to protect their integrity and confidentiality.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod -R 644 /etc/kubernetes/pki/*.crt ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c '%a' /etc/kubernetes/pki/*.crt ``` Verify that the permissions are `644` or more restrictive. or ``` ls -l /etc/kubernetes/pki/*.crt ``` Verify -rw------", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/", + "DefaultValue": "By default, the certificates used by Kubernetes are set to have permissions of `644`" + } + ] + }, + { + "Id": "1.1.21", + "Description": "Ensure that the Kubernetes PKI key file permissions are set to 600", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that Kubernetes PKI key files have permissions of `600`.", + "RationaleStatement": "Kubernetes makes use of a number of key files as part of the operation of its components. The permissions on these files should be set to `600` to protect their integrity and confidentiality.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod -R 600 /etc/kubernetes/pki/*.key ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c '%a' /etc/kubernetes/pki/*.key ``` Verify that the permissions are `600` or more restrictive. or ``` ls -l /etc/kubernetes/pki/*.key ``` Verify that the permissions are `-rw------`", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/", + "DefaultValue": "By default, the keys used by Kubernetes are set to have permissions of `600`" + } + ] + }, + { + "Id": "1.2.1", + "Description": "Ensure that the --anonymous-auth argument is set to false", + "Checks": [ + "apiserver_anonymous_requests" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Disable anonymous requests to the API server.", + "RationaleStatement": "When enabled, requests that are not rejected by other configured authentication methods are treated as anonymous requests. These requests are then served by the API server. You should rely on authentication to authorize access and disallow anonymous requests. If you are using RBAC authorization, it is generally considered reasonable to allow anonymous access to the API Server for health checks and discovery purposes, and hence this recommendation is not scored. However, you should consider whether anonymous discovery is an acceptable risk for your purposes.", + "ImpactStatement": "Anonymous requests will be rejected.", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the below parameter. ``` --anonymous-auth=false ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--anonymous-auth` argument is set to `false`. Alternative Audit Method ``` kubectl get pod -nkube-system -lcomponent=kube-apiserver -o=jsonpath='{range .items[*]}{.spec.containers[*].command} {\\}{end}' | grep '--anonymous-auth' | grep -i false ``` If the exit code is '1', then the control isn't present / failed", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/:https://kubernetes.io/docs/admin/authentication/#anonymous-requests", + "DefaultValue": "By default, anonymous access is enabled." + } + ] + }, + { + "Id": "1.2.2", + "Description": "Ensure that the --token-auth-file parameter is not set", + "Checks": [ + "apiserver_no_token_auth_file" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Do not use token based authentication.", + "RationaleStatement": "The token-based authentication utilizes static tokens to authenticate requests to the apiserver. The tokens are stored in clear-text in a file on the apiserver, and cannot be revoked or rotated without restarting the apiserver. Hence, do not use static token-based authentication.", + "ImpactStatement": "You will have to configure and use alternate authentication mechanisms such as certificates. Static token based authentication could not be used.", + "RemediationProcedure": "Follow the documentation and configure alternate mechanisms for authentication. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the master node and remove the `--token-auth-file=` parameter.", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--token-auth-file` argument does not exist. Alternative Audit Method ``` kubectl get pod -nkube-system -lcomponent=kube-apiserver -o=jsonpath='{range .items[*]}{.spec.containers[*].command} {\\}{end}' | grep '--token-auth-file' | grep -i false ``` If the exit code is '1', then the control isn't present / failed", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/authentication/#static-token-file:https://kubernetes.io/docs/admin/kube-apiserver/", + "DefaultValue": "By default, `--token-auth-file` argument is not set." + } + ] + }, + { + "Id": "1.2.3", + "Description": "Ensure that the DenyServiceExternalIPs is set", + "Checks": [ + "apiserver_deny_service_external_ips" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "This admission controller rejects all net-new usage of the Service field externalIPs.", + "RationaleStatement": "Most users do not need the ability to set the `externalIPs` field for a `Service` at all, and cluster admins should consider disabling this functionality by enabling the `DenyServiceExternalIPs` admission controller. Clusters that do need to allow this functionality should consider using some custom policy to manage its usage.", + "ImpactStatement": "When enabled, users of the cluster may not create new Services which use externalIPs and may not add new values to externalIPs on existing Service objects.", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the master node and append the Kubernetes API server flag --enable-admission-plugins with the DenyServiceExternalIPs plugin. Note, the Kubernetes API server flag --enable-admission-plugins takes a comma-delimited list of admission control plugins to be enabled, even if they are in the list of plugins enabled by default. ``` kube-apiserver --enable-admission-plugins=DenyServiceExternalIPs ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `DenyServiceExternalIPs' argument exist as a string value in --enable-admission-plugins.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/:https://kubernetes.io/docs/admin/kube-apiserver/", + "DefaultValue": "By default, --enable-admission-plugins=DenyServiceExternalIP argument is not set, and the use of externalIPs is authorized." + } + ] + }, + { + "Id": "1.2.4", + "Description": "Ensure that the --kubelet-client-certificate and --kubelet-client-key arguments are set as appropriate", + "Checks": [ + "apiserver_kubelet_tls_auth" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enable certificate based kubelet authentication.", + "RationaleStatement": "The apiserver, by default, does not authenticate itself to the kubelet's HTTPS endpoints. The requests from the apiserver are treated anonymously. You should set up certificate-based kubelet authentication to ensure that the apiserver authenticates itself to kubelets when submitting requests.", + "ImpactStatement": "You require TLS to be configured on apiserver as well as kubelets.", + "RemediationProcedure": "Follow the Kubernetes documentation and set up the TLS connection between the apiserver and kubelets. Then, edit API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the kubelet client certificate and key parameters as below. ``` --kubelet-client-certificate= --kubelet-client-key= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--kubelet-client-certificate` and `--kubelet-client-key` arguments exist and they are set as appropriate. Alternative Audit ``` kubectl get pod -nkube-system -lcomponent=kube-apiserver -o=jsonpath='{range .items[*]}{.spec.containers[*].command} {\\}{end}' | grep '--kubelet-client-certificate' | grep -i false ``` If the exit code is '1', then the control isn't present / failed", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/:https://kubernetes.io/docs/admin/kubelet-authentication-authorization/:https://kubernetes.io/docs/concepts/cluster-administration/master-node-communication/#apiserver---kubelet", + "DefaultValue": "By default, certificate-based kubelet authentication is not set." + } + ] + }, + { + "Id": "1.2.5", + "Description": "Ensure that the --kubelet-certificate-authority argument is set as appropriate", + "Checks": [ + "apiserver_kubelet_cert_auth" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Verify kubelet's certificate before establishing connection.", + "RationaleStatement": "The connections from the apiserver to the kubelet are used for fetching logs for pods, attaching (through kubectl) to running pods, and using the kubelet’s port-forwarding functionality. These connections terminate at the kubelet’s HTTPS endpoint. By default, the apiserver does not verify the kubelet’s serving certificate, which makes the connection subject to man-in-the-middle attacks, and unsafe to run over untrusted and/or public networks.", + "ImpactStatement": "You require TLS to be configured on apiserver as well as kubelets.", + "RemediationProcedure": "Follow the Kubernetes documentation and setup the TLS connection between the apiserver and kubelets. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--kubelet-certificate-authority` parameter to the path to the cert file for the certificate authority. ``` --kubelet-certificate-authority= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--kubelet-certificate-authority` argument exists and is set as appropriate. Alternative Audit ``` kubectl get pod -nkube-system -lcomponent=kube-apiserver -o=jsonpath='{range .items[]}{.spec.containers[].command} {\\}{end}' | grep '--kubelet-certificate-authority' | grep -i false ``` If the exit code is '1', then the control isn't present / failed", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/:https://kubernetes.io/docs/admin/kubelet-authentication-authorization/:https://kubernetes.io/docs/concepts/cluster-administration/master-node-communication/#apiserver---kubelet", + "DefaultValue": "By default, `--kubelet-certificate-authority` argument is not set." + } + ] + }, + { + "Id": "1.2.6", + "Description": "Ensure that the --authorization-mode argument is not set to AlwaysAllow", + "Checks": [ + "apiserver_auth_mode_not_always_allow" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Do not always authorize all requests.", + "RationaleStatement": "The API Server, can be configured to allow all requests. This mode should not be used on any production cluster.", + "ImpactStatement": "Only authorized requests will be served.", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--authorization-mode` parameter to values other than `AlwaysAllow`. One such example could be as below. ``` --authorization-mode=RBAC ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--authorization-mode` argument exists and is not set to `AlwaysAllow`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/admin/authorization/", + "DefaultValue": "By default, `AlwaysAllow` is not enabled." + } + ] + }, + { + "Id": "1.2.7", + "Description": "Ensure that the --authorization-mode argument includes Node", + "Checks": [ + "apiserver_auth_mode_include_node" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Restrict kubelet nodes to reading only objects associated with them.", + "RationaleStatement": "The `Node` authorization mode only allows kubelets to read `Secret`, `ConfigMap`, `PersistentVolume`, and `PersistentVolumeClaim` objects associated with their nodes.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--authorization-mode` parameter to a value that includes `Node`. ``` --authorization-mode=Node,RBAC ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--authorization-mode` argument exists and is set to a value to include `Node`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/:https://kubernetes.io/docs/admin/authorization/node/:https://github.com/kubernetes/kubernetes/pull/46076:https://acotten.com/post/kube17-security", + "DefaultValue": "By default, `Node` authorization is not enabled." + } + ] + }, + { + "Id": "1.2.8", + "Description": "Ensure that the --authorization-mode argument includes RBAC", + "Checks": [ + "apiserver_auth_mode_include_rbac" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Turn on Role Based Access Control.", + "RationaleStatement": "Role Based Access Control (RBAC) allows fine-grained control over the operations that different entities can perform on different objects in the cluster. It is recommended to use the RBAC authorization mode.", + "ImpactStatement": "When RBAC is enabled you will need to ensure that appropriate RBAC settings (including Roles, RoleBindings and ClusterRoleBindings) are configured to allow appropriate access.", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--authorization-mode` parameter to a value that includes `RBAC`, for example: ``` --authorization-mode=Node,RBAC ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--authorization-mode` argument exists and is set to a value to include `RBAC`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/access-authn-authz/rbac/", + "DefaultValue": "By default, `RBAC` authorization is not enabled." + } + ] + }, + { + "Id": "1.2.9", + "Description": "Ensure that the admission control plugin EventRateLimit is set", + "Checks": [ + "apiserver_event_rate_limit" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Limit the rate at which the API server accepts requests.", + "RationaleStatement": "Using `EventRateLimit` admission control enforces a limit on the number of events that the API Server will accept in a given time slice. A misbehaving workload could overwhelm and DoS the API Server, making it unavailable. This particularly applies to a multi-tenant cluster, where there might be a small percentage of misbehaving tenants which could have a significant impact on the performance of the cluster overall. Hence, it is recommended to limit the rate of events that the API server will accept.", + "ImpactStatement": "You need to carefully tune in limits as per your environment.", + "RemediationProcedure": "Follow the Kubernetes documentation and set the desired limits in a configuration file. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` and set the below parameters. ``` --enable-admission-plugins=...,EventRateLimit,... --admission-control-config-file= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--enable-admission-plugins` argument is set to a value that includes `EventRateLimit`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#eventratelimit:https://github.com/staebler/community/blob/9873b632f4d99b5d99c38c9b15fe2f8b93d0a746/contributors/design-proposals/admission_control_event_rate_limit.md", + "DefaultValue": "By default, `EventRateLimit` is not set." + } + ] + }, + { + "Id": "1.2.10", + "Description": "Ensure that the admission control plugin AlwaysAdmit is not set", + "Checks": [ + "apiserver_no_always_admit_plugin" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Do not allow all requests.", + "RationaleStatement": "Setting admission control plugin `AlwaysAdmit` allows all requests and do not filter any requests. The `AlwaysAdmit` admission controller was deprecated in Kubernetes v1.13. Its behavior was equivalent to turning off all admission controllers.", + "ImpactStatement": "Only requests explicitly allowed by the admissions control plugins would be served.", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and either remove the `--enable-admission-plugins` parameter, or set it to a value that does not include `AlwaysAdmit`.", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that if the `--enable-admission-plugins` argument is set, its value does not include `AlwaysAdmit`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#alwaysadmit", + "DefaultValue": "`AlwaysAdmit` is not in the list of default admission plugins." + } + ] + }, + { + "Id": "1.2.11", + "Description": "Ensure that the admission control plugin AlwaysPullImages is set", + "Checks": [ + "apiserver_always_pull_images_plugin" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Always pull images.", + "RationaleStatement": "Setting admission control policy to `AlwaysPullImages` forces every new pod to pull the required images every time. In a multi-tenant cluster users can be assured that their private images can only be used by those who have the credentials to pull them. Without this admission control policy, once an image has been pulled to a node, any pod from any user can use it simply by knowing the image’s name, without any authorization check against the image ownership. When this plug-in is enabled, images are always pulled prior to starting containers, which means valid credentials are required.", + "ImpactStatement": "Credentials would be required to pull the private images every time. Also, in trusted environments, this might increases load on network, registry, and decreases speed. This setting could impact offline or isolated clusters, which have images preloaded and do not have access to a registry to pull in-use images. This setting is not appropriate for clusters which use this configuration.", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--enable-admission-plugins` parameter to include `AlwaysPullImages`. ``` --enable-admission-plugins=...,AlwaysPullImages,... ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--enable-admission-plugins` argument is set to a value that includes `AlwaysPullImages`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#alwayspullimages", + "DefaultValue": "By default, `AlwaysPullImages` is not set." + } + ] + }, + { + "Id": "1.2.12", + "Description": "Ensure that the admission control plugin ServiceAccount is set", + "Checks": [ + "apiserver_service_account_plugin" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Automate service accounts management.", + "RationaleStatement": "When you create a pod, if you do not specify a service account, it is automatically assigned the `default` service account in the same namespace. You should create your own service account and let the API server manage its security tokens.", + "ImpactStatement": "None.", + "RemediationProcedure": "Follow the documentation and create `ServiceAccount` objects as per your environment. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the master node and ensure that the `--disable-admission-plugins` parameter is set to a value that does not include `ServiceAccount`.", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--disable-admission-plugins` argument is set to a value that does not includes `ServiceAccount`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#serviceaccount:https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/", + "DefaultValue": "By default, `ServiceAccount` is set." + } + ] + }, + { + "Id": "1.2.13", + "Description": "Ensure that the admission control plugin NamespaceLifecycle is set", + "Checks": [ + "apiserver_namespace_lifecycle_plugin" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Reject creating objects in a namespace that is undergoing termination.", + "RationaleStatement": "Setting admission control policy to `NamespaceLifecycle` ensures that objects cannot be created in non-existent namespaces, and that namespaces undergoing termination are not used for creating the new objects. This is recommended to enforce the integrity of the namespace termination process and also for the availability of the newer objects.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--disable-admission-plugins` parameter to ensure it does not include `NamespaceLifecycle`.", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--disable-admission-plugins` argument is set to a value that does not include `NamespaceLifecycle`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#namespacelifecycle", + "DefaultValue": "By default, `NamespaceLifecycle` is set." + } + ] + }, + { + "Id": "1.2.14", + "Description": "Ensure that the admission control plugin NodeRestriction is set", + "Checks": [ + "apiserver_node_restriction_plugin" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Limit the `Node` and `Pod` objects that a kubelet could modify.", + "RationaleStatement": "Using the `NodeRestriction` plug-in ensures that the kubelet is restricted to the `Node` and `Pod` objects that it could modify as defined. Such kubelets will only be allowed to modify their own `Node` API object, and only modify `Pod` API objects that are bound to their node.", + "ImpactStatement": "None", + "RemediationProcedure": "Follow the Kubernetes documentation and configure `NodeRestriction` plug-in on kubelets. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the master node and set the `--enable-admission-plugins` parameter to a value that includes `NodeRestriction`. ``` --enable-admission-plugins=...,NodeRestriction,... ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--enable-admission-plugins` argument is set to a value that includes `NodeRestriction`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#noderestriction:https://kubernetes.io/docs/reference/access-authn-authz/node/", + "DefaultValue": "By default, `NodeRestriction` is not set." + } + ] + }, + { + "Id": "1.2.15", + "Description": "Ensure that the --profiling argument is set to false", + "Checks": [ + "apiserver_disable_profiling" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Disable profiling, if not needed.", + "RationaleStatement": "Profiling allows for the identification of specific performance bottlenecks. It generates a significant amount of program data that could potentially be exploited to uncover system and program details. If you are not experiencing any bottlenecks and do not need the profiler for troubleshooting purposes, it is recommended to turn it off to reduce the potential attack surface.", + "ImpactStatement": "Profiling information would not be available.", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the below parameter. ``` --profiling=false ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--profiling` argument is set to `false`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "DefaultValue": "By default, profiling is enabled." + } + ] + }, + { + "Id": "1.2.16", + "Description": "Ensure that the --audit-log-path argument is set", + "Checks": [ + "apiserver_audit_log_path_set" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enable auditing on the Kubernetes API Server and set the desired audit log path.", + "RationaleStatement": "Auditing the Kubernetes API Server provides a security-relevant chronological set of records documenting the sequence of activities that have affected system by individual users, administrators or other components of the system. Even though currently, Kubernetes provides only basic audit capabilities, it should be enabled. You can enable it by setting an appropriate audit log path.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--audit-log-path` parameter to a suitable path and file where you would like audit logs to be written, for example: ``` --audit-log-path=/var/log/apiserver/audit.log ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--audit-log-path` argument is set as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/:https://github.com/kubernetes/enhancements/issues/22", + "DefaultValue": "By default, auditing is not enabled." + } + ] + }, + { + "Id": "1.2.17", + "Description": "Ensure that the --audit-log-maxage argument is set to 30 or as appropriate", + "Checks": [ + "apiserver_audit_log_maxage_set" + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 30 + } + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Retain the logs for at least 30 days or as appropriate.", + "RationaleStatement": "Retaining logs for at least 30 days ensures that you can go back in time and investigate or correlate any events. Set your audit log retention period to 30 days or as per your business requirements.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--audit-log-maxage` parameter to 30 or as an appropriate number of days: ``` --audit-log-maxage=30 ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--audit-log-maxage` argument is set to `30` or as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/:https://github.com/kubernetes/enhancements/issues/22", + "DefaultValue": "By default, auditing is not enabled." + } + ] + }, + { + "Id": "1.2.18", + "Description": "Ensure that the --audit-log-maxbackup argument is set to 10 or as appropriate", + "Checks": [ + "apiserver_audit_log_maxbackup_set" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Retain 10 or an appropriate number of old log files.", + "RationaleStatement": "Kubernetes automatically rotates the log files. Retaining old log files ensures that you would have sufficient log data available for carrying out any investigation or correlation. For example, if you have set file size of 100 MB and the number of old log files to keep as 10, you would approximate have 1 GB of log data that you could potentially use for your analysis.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--audit-log-maxbackup` parameter to 10 or to an appropriate value. ``` --audit-log-maxbackup=10 ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--audit-log-maxbackup` argument is set to `10` or as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/:https://github.com/kubernetes/enhancements/issues/22", + "DefaultValue": "By default, auditing is not enabled." + } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxbackup_set", + "ConfigKey": "audit_log_maxbackup", + "Operator": "gte", + "Value": 10 + } + ] + }, + { + "Id": "1.2.19", + "Description": "Ensure that the --audit-log-maxsize argument is set to 100 or as appropriate", + "Checks": [ + "apiserver_audit_log_maxsize_set" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Rotate log files on reaching 100 MB or as appropriate.", + "RationaleStatement": "Kubernetes automatically rotates the log files. Retaining old log files ensures that you would have sufficient log data available for carrying out any investigation or correlation. If you have set file size of 100 MB and the number of old log files to keep as 10, you would approximate have 1 GB of log data that you could potentially use for your analysis.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--audit-log-maxsize` parameter to an appropriate size in MB. For example, to set it as 100 MB: ``` --audit-log-maxsize=100 ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--audit-log-maxsize` argument is set to `100` or as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/:https://github.com/kubernetes/enhancements/issues/22", + "DefaultValue": "By default, auditing is not enabled." + } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxsize_set", + "ConfigKey": "audit_log_maxsize", + "Operator": "gte", + "Value": 100 + } + ] + }, + { + "Id": "1.2.20", + "Description": "Ensure that the --request-timeout argument is set as appropriate", + "Checks": [ + "apiserver_request_timeout_set" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Set global request timeout for API server requests as appropriate.", + "RationaleStatement": "Setting global request timeout allows extending the API server request timeout limit to a duration appropriate to the user's connection speed. By default, it is set to 60 seconds which might be problematic on slower connections making cluster resources inaccessible once the data volume for requests exceeds what can be transmitted in 60 seconds. But, setting this timeout limit to be too large can exhaust the API server resources making it prone to Denial-of-Service attack. Hence, it is recommended to set this limit as appropriate and change the default limit of 60 seconds only if needed.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` and set the below parameter as appropriate and if needed. For example, ``` --request-timeout=300s ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--request-timeout` argument is either not set or set to an appropriate value.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/kubernetes/kubernetes/pull/51415", + "DefaultValue": "By default, `--request-timeout` is set to 60 seconds." + } + ] + }, + { + "Id": "1.2.21", + "Description": "Ensure that the --service-account-lookup argument is set to true", + "Checks": [ + "apiserver_service_account_lookup_true" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Validate service account before validating token.", + "RationaleStatement": "If `--service-account-lookup` is not enabled, the apiserver only verifies that the authentication token is valid, and does not validate that the service account token mentioned in the request is actually present in etcd. This allows using a service account token even after the corresponding service account is deleted. This is an example of time of check to time of use security issue.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the below parameter. ``` --service-account-lookup=true ``` Alternatively, you can delete the `--service-account-lookup` parameter from this file so that the default takes effect.", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that if the `--service-account-lookup` argument exists it is set to `true`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/kubernetes/kubernetes/issues/24167:https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use", + "DefaultValue": "By default, `--service-account-lookup` argument is set to `true`." + } + ] + }, + { + "Id": "1.2.22", + "Description": "Ensure that the --service-account-key-file argument is set as appropriate", + "Checks": [ + "apiserver_service_account_key_file_set" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Explicitly set a service account public key file for service accounts on the apiserver.", + "RationaleStatement": "By default, if no `--service-account-key-file` is specified to the apiserver, it uses the private key from the TLS serving certificate to verify service account tokens. To ensure that the keys for service account tokens could be rotated as needed, a separate public/private key pair should be used for signing service account tokens. Hence, the public key should be specified to the apiserver with `--service-account-key-file`.", + "ImpactStatement": "The corresponding private key must be provided to the controller manager. You would need to securely maintain the key file and rotate the keys based on your organization's key rotation policy.", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--service-account-key-file` parameter to the public key file for service accounts: ``` --service-account-key-file= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--service-account-key-file` argument exists and is set as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/kubernetes/kubernetes/issues/24167", + "DefaultValue": "By default, `--service-account-key-file` argument is not set." + } + ] + }, + { + "Id": "1.2.23", + "Description": "Ensure that the --etcd-certfile and --etcd-keyfile arguments are set as appropriate", + "Checks": [ + "apiserver_etcd_tls_config" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "etcd should be configured to make use of TLS encryption for client connections.", + "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be protected by client authentication. This requires the API server to identify itself to the etcd server using a client certificate and key.", + "ImpactStatement": "TLS and client certificate authentication must be configured for etcd.", + "RemediationProcedure": "Follow the Kubernetes documentation and set up the TLS connection between the apiserver and etcd. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the master node and set the etcd certificate and key file parameters. ``` --etcd-certfile= --etcd-keyfile= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--etcd-certfile` and `--etcd-keyfile` arguments exist and they are set as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "DefaultValue": "By default, `--etcd-certfile` and `--etcd-keyfile` arguments are not set" + } + ] + }, + { + "Id": "1.2.24", + "Description": "Ensure that the --tls-cert-file and --tls-private-key-file arguments are set as appropriate", + "Checks": [ + "apiserver_tls_config" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Setup TLS connection on the API server.", + "RationaleStatement": "API server communication contains sensitive parameters that should remain encrypted in transit. Configure the API server to serve only HTTPS traffic.", + "ImpactStatement": "TLS and client certificate authentication must be configured for your Kubernetes cluster deployment.", + "RemediationProcedure": "Follow the Kubernetes documentation and set up the TLS connection on the apiserver. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the master node and set the TLS certificate and private key file parameters. ``` --tls-cert-file= --tls-private-key-file= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--tls-cert-file` and `--tls-private-key-file` arguments exist and they are set as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/kelseyhightower/docker-kubernetes-tls-guide", + "DefaultValue": "By default, `--tls-cert-file` and `--tls-private-key-file` are presented and created for use." + } + ] + }, + { + "Id": "1.2.25", + "Description": "Ensure that the --client-ca-file argument is set as appropriate", + "Checks": [ + "apiserver_client_ca_file_set" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Setup TLS connection on the API server.", + "RationaleStatement": "API server communication contains sensitive parameters that should remain encrypted in transit. Configure the API server to serve only HTTPS traffic. If `--client-ca-file` argument is set, any request presenting a client certificate signed by one of the authorities in the `client-ca-file` is authenticated with an identity corresponding to the CommonName of the client certificate.", + "ImpactStatement": "TLS and client certificate authentication must be configured for your Kubernetes cluster deployment.", + "RemediationProcedure": "Follow the Kubernetes documentation and set up the TLS connection on the apiserver. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the master node and set the client certificate authority file. ``` --client-ca-file= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--client-ca-file` argument exists and it is set as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/kelseyhightower/docker-kubernetes-tls-guide", + "DefaultValue": "By default, `--client-ca-file` argument is not set." + } + ] + }, + { + "Id": "1.2.26", + "Description": "Ensure that the --etcd-cafile argument is set as appropriate", + "Checks": [ + "apiserver_etcd_cafile_set" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "etcd should be configured to make use of TLS encryption for client connections.", + "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be protected by client authentication. This requires the API server to identify itself to the etcd server using a SSL Certificate Authority file.", + "ImpactStatement": "TLS and client certificate authentication must be configured for etcd.", + "RemediationProcedure": "Follow the Kubernetes documentation and set up the TLS connection between the apiserver and etcd. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the master node and set the etcd certificate authority file parameter. ``` --etcd-cafile= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--etcd-cafile` argument exists and it is set as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "DefaultValue": "By default, `--etcd-cafile` is not set." + } + ] + }, + { + "Id": "1.2.27", + "Description": "Ensure that the --encryption-provider-config argument is set as appropriate", + "Checks": [ + "apiserver_encryption_provider_config_set" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Encrypt etcd key-value store.", + "RationaleStatement": "etcd is a highly available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be encrypted at rest to avoid any disclosures.", + "ImpactStatement": "None", + "RemediationProcedure": "Follow the Kubernetes documentation and configure a `EncryptionConfig` file. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the master node and set the `--encryption-provider-config` parameter to the path of that file: ``` --encryption-provider-config= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--encryption-provider-config` argument is set to a `EncryptionConfig` file. Additionally, ensure that the `EncryptionConfig` file has all the desired `resources` covered especially any secrets.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/:https://acotten.com/post/kube17-security:https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/kubernetes/enhancements/issues/92", + "DefaultValue": "By default, `--encryption-provider-config` is not set." + } + ] + }, + { + "Id": "1.2.28", + "Description": "Ensure that encryption providers are appropriately configured", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Where `etcd` encryption is used, appropriate providers should be configured.", + "RationaleStatement": "Where `etcd` encryption is used, it is important to ensure that the appropriate set of encryption providers is used. Currently, the `aescbc`, `kms`, and `secretbox` are likely to be appropriate options.", + "ImpactStatement": "None", + "RemediationProcedure": "Follow the Kubernetes documentation and configure a `EncryptionConfig` file. In this file, choose `aescbc`, `kms`, or `secretbox` as the encryption provider.", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Get the `EncryptionConfig` file set for `--encryption-provider-config` argument. Verify that `aescbc`, `kms`, or `secretbox` is set as the encryption provider for all the desired `resources`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/:https://acotten.com/post/kube17-security:https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/kubernetes/enhancements/issues/92:https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/#providers", + "DefaultValue": "By default, no encryption provider is set." + } + ] + }, + { + "Id": "1.2.29", + "Description": "Ensure that the API Server only makes use of Strong Cryptographic Ciphers", + "Checks": [ + "apiserver_strong_ciphers_only" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that the API server is configured to only use strong cryptographic ciphers.", + "RationaleStatement": "TLS ciphers have had a number of known vulnerabilities and weaknesses, which can reduce the protection provided by them. By default Kubernetes supports a number of TLS cipher suites including some that have security concerns, weakening the protection provided.", + "ImpactStatement": "API server clients that cannot support modern cryptographic ciphers will not be able to make connections to the API server.", + "RemediationProcedure": "Edit the API server pod specification file /etc/kubernetes/manifests/kube-apiserver.yaml on the Control Plane node and set the below parameter. ``` --tls-cipher-suites=TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256. ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--tls-cipher-suites` argument returns a value that is in this list `TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256`.", + "AdditionalInformation": "Insecure values: TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, TLS_ECDHE_ECDSA_WITH_RC4_128_SHA, TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA, TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, TLS_ECDHE_RSA_WITH_RC4_128_SHA, TLS_RSA_WITH_3DES_EDE_CBC_SHA, TLS_RSA_WITH_AES_128_CBC_SHA, TLS_RSA_WITH_AES_128_CBC_SHA256, TLS_RSA_WITH_AES_128_GCM_SHA256, TLS_RSA_WITH_AES_256_CBC_SHA, TLS_RSA_WITH_AES_256_GCM_SHA384, TLS_RSA_WITH_RC4_128_SHA.", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/ssllabs/research/wiki/SSL-and-TLS-Deployment-Best-Practices#23-use-secure-cipher-suites", + "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers" + } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_strong_ciphers_only", + "ConfigKey": "apiserver_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256" + ] + } + ] + }, + { + "Id": "1.2.30", + "Description": "Ensure that the --service-account-extend-token-expiration parameter is set to false", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "By default Kubernetes extends service account token lifetimes to one year to aid in transition from the legacy token settings.", + "RationaleStatement": "This default setting is not ideal for security as it ignores other settings related to maximum token lifetime and means that a lost or stolen credential could be valid for an extended period of time.", + "ImpactStatement": "Disabling this setting means that the service account token expiry set in the cluster will be enforced, and service account tokens will expire at the end of that time frame.", + "RemediationProcedure": "Edit the API server pod specification file /etc/kubernetes/manifests/kube-apiserver.yaml on the Control Plane node and set the --service-account-extend-token-expiration parameter to false. ``` --service-account-extend-token-expiration=false ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the --service-account-extend-token-expiration argument is set to false.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "DefaultValue": "By default, this parameter is set to true" + } + ] + }, + { + "Id": "1.3.1", + "Description": "Ensure that the --terminated-pod-gc-threshold argument is set as appropriate", + "Checks": [ + "controllermanager_garbage_collection" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.3 Controller Manager", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Activate garbage collector on pod termination, as appropriate.", + "RationaleStatement": "Garbage collection is important to ensure sufficient resource availability and avoiding degraded performance and availability. In the worst case, the system might crash or just be unusable for a long period of time. The current setting for garbage collection is 12,500 terminated pods which might be too high for your system to sustain. Based on your system resources and tests, choose an appropriate threshold value to activate garbage collection.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the Controller Manager pod specification file `/etc/kubernetes/manifests/kube-controller-manager.yaml` on the Control Plane node and set the `--terminated-pod-gc-threshold` to an appropriate threshold, for example: ``` --terminated-pod-gc-threshold=10 ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-controller-manager ``` Verify that the `--terminated-pod-gc-threshold` argument is set as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-controller-manager/:https://github.com/kubernetes/kubernetes/issues/28484", + "DefaultValue": "By default, `--terminated-pod-gc-threshold` is set to `12500`." + } + ] + }, + { + "Id": "1.3.2", + "Description": "Ensure that the --profiling argument is set to false", + "Checks": [ + "controllermanager_disable_profiling" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.3 Controller Manager", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Disable profiling, if not needed.", + "RationaleStatement": "Profiling allows for the identification of specific performance bottlenecks. It generates a significant amount of program data that could potentially be exploited to uncover system and program details. If you are not experiencing any bottlenecks and do not need the profiler for troubleshooting purposes, it is recommended to turn it off to reduce the potential attack surface.", + "ImpactStatement": "Profiling information would not be available.", + "RemediationProcedure": "Edit the Controller Manager pod specification file `/etc/kubernetes/manifests/kube-controller-manager.yaml` on the Control Plane node and set the below parameter. ``` --profiling=false ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-controller-manager ``` Verify that the `--profiling` argument is set to `false`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-controller-manager/:https://github.com/kubernetes/community/blob/master/contributors/devel/profiling.md", + "DefaultValue": "By default, profiling is enabled." + } + ] + }, + { + "Id": "1.3.3", + "Description": "Ensure that the --use-service-account-credentials argument is set to true", + "Checks": [ + "controllermanager_service_account_credentials" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.3 Controller Manager", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Use individual service account credentials for each controller.", + "RationaleStatement": "The controller manager creates a service account per controller in the `kube-system` namespace, generates a credential for it, and builds a dedicated API client with that service account credential for each controller loop to use. Setting the `--use-service-account-credentials` to `true` runs each control loop within the controller manager using a separate service account credential. When used in combination with RBAC, this ensures that the control loops run with the minimum permissions required to perform their intended tasks.", + "ImpactStatement": "Whatever authorizer is configured for the cluster, it must grant sufficient permissions to the service accounts to perform their intended tasks. When using the RBAC authorizer, those roles are created and bound to the appropriate service accounts in the `kube-system` namespace automatically with default roles and rolebindings that are auto-reconciled on startup. If using other authorization methods (ABAC, Webhook, etc), the cluster deployer is responsible for granting appropriate permissions to the service accounts (the required permissions can be seen by inspecting the `controller-roles.yaml` and `controller-role-bindings.yaml` files for the RBAC roles.", + "RemediationProcedure": "Edit the Controller Manager pod specification file `/etc/kubernetes/manifests/kube-controller-manager.yaml` on the Control Plane node to set the below parameter. ``` --use-service-account-credentials=true ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-controller-manager ``` Verify that the `--use-service-account-credentials` argument is set to `true`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-controller-manager/:https://kubernetes.io/docs/admin/service-accounts-admin/:https://github.com/kubernetes/kubernetes/blob/release-1.6/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/controller-roles.yaml:https://github.com/kubernetes/kubernetes/blob/release-1.6/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/controller-role-bindings.yaml:https://kubernetes.io/docs/admin/authorization/rbac/#controller-roles", + "DefaultValue": "By default, `--use-service-account-credentials` is set to false." + } + ] + }, + { + "Id": "1.3.4", + "Description": "Ensure that the --service-account-private-key-file argument is set as appropriate", + "Checks": [ + "controllermanager_service_account_private_key_file" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.3 Controller Manager", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Explicitly set a service account private key file for service accounts on the controller manager.", + "RationaleStatement": "To ensure that keys for service account tokens can be rotated as needed, a separate public/private key pair should be used for signing service account tokens. The private key should be specified to the controller manager with `--service-account-private-key-file` as appropriate.", + "ImpactStatement": "You would need to securely maintain the key file and rotate the keys based on your organization's key rotation policy.", + "RemediationProcedure": "Edit the Controller Manager pod specification file `/etc/kubernetes/manifests/kube-controller-manager.yaml` on the Control Plane node and set the `--service-account-private-key-file` parameter to the private key file for service accounts. ``` --service-account-private-key-file= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-controller-manager ``` Verify that the `--service-account-private-key-file` argument is set as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-controller-manager/", + "DefaultValue": "By default, `--service-account-private-key-file` it not set." + } + ] + }, + { + "Id": "1.3.5", + "Description": "Ensure that the --root-ca-file argument is set as appropriate", + "Checks": [ + "controllermanager_root_ca_file_set" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.3 Controller Manager", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Allow pods to verify the API server's serving certificate before establishing connections.", + "RationaleStatement": "Processes running within pods that need to contact the API server must verify the API server's serving certificate. Failing to do so could be a subject to man-in-the-middle attacks. Providing the root certificate for the API server's serving certificate to the controller manager with the `--root-ca-file` argument allows the controller manager to inject the trusted bundle into pods so that they can verify TLS connections to the API server.", + "ImpactStatement": "You need to setup and maintain root certificate authority file.", + "RemediationProcedure": "Edit the Controller Manager pod specification file `/etc/kubernetes/manifests/kube-controller-manager.yaml` on the Control Plane node and set the `--root-ca-file` parameter to the certificate bundle file`. ``` --root-ca-file= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-controller-manager ``` Verify that the `--root-ca-file` argument exists and is set to a certificate bundle file containing the root certificate for the API server's serving certificate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-controller-manager/:https://github.com/kubernetes/kubernetes/issues/11000", + "DefaultValue": "By default, `--root-ca-file` is not set." + } + ] + }, + { + "Id": "1.3.6", + "Description": "Ensure that the RotateKubeletServerCertificate argument is set to true", + "Checks": [ + "controllermanager_rotate_kubelet_server_cert" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.3 Controller Manager", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enable kubelet server certificate rotation on controller-manager.", + "RationaleStatement": "`RotateKubeletServerCertificate` causes the kubelet to both request a serving certificate after bootstrapping its client credentials and rotate the certificate as its existing credentials expire. This automated periodic rotation ensures that the there are no downtimes due to expired certificates and thus addressing availability in the CIA security triad. Note: This recommendation only applies if you let kubelets get their certificates from the API server. In case your kubelet certificates come from an outside authority/tool (e.g. Vault) then you need to take care of rotation yourself.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the Controller Manager pod specification file `/etc/kubernetes/manifests/kube-controller-manager.yaml` on the Control Plane node and set the `--feature-gates` parameter to include `RotateKubeletServerCertificate=true`. ``` --feature-gates=RotateKubeletServerCertificate=true ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-controller-manager ``` Verify that `RotateKubeletServerCertificate` argument exists and is set to `true`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet-tls-bootstrapping/#approval-controller:https://github.com/kubernetes/features/issues/267:https://github.com/kubernetes/kubernetes/pull/45059:https://kubernetes.io/docs/admin/kube-controller-manager/", + "DefaultValue": "By default, `RotateKubeletServerCertificate` is set to true this recommendation verifies that it has not been disabled." + } + ] + }, + { + "Id": "1.3.7", + "Description": "Ensure that the --bind-address argument is set to 127.0.0.1", + "Checks": [ + "controllermanager_bind_address" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.3 Controller Manager", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Do not bind the Controller Manager service to non-loopback insecure addresses.", + "RationaleStatement": "The Controller Manager API service which runs on port 10252/TCP by default is used for health and metrics information and is available without authentication or encryption. As such it should only be bound to a localhost interface, to minimize the cluster's attack surface", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the Controller Manager pod specification file `/etc/kubernetes/manifests/kube-controller-manager.yaml` on the Control Plane node and ensure the correct value for the `--bind-address` parameter", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-controller-manager ``` Verify that the `--bind-address` argument is set to 127.0.0.1", + "AdditionalInformation": "Although the current Kubernetes documentation site says that `--address` is deprecated in favour of `--bind-address` Kubeadm 1.11 still makes use of `--address`", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/", + "DefaultValue": "By default, the `--bind-address` parameter is set to 0.0.0.0" + } + ] + }, + { + "Id": "1.4.1", + "Description": "Ensure that the --profiling argument is set to false", + "Checks": [ + "scheduler_profiling" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.4 Scheduler", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Disable profiling, if not needed.", + "RationaleStatement": "Profiling allows for the identification of specific performance bottlenecks. It generates a significant amount of program data that could potentially be exploited to uncover system and program details. If you are not experiencing any bottlenecks and do not need the profiler for troubleshooting purposes, it is recommended to turn it off to reduce the potential attack surface.", + "ImpactStatement": "Profiling information would not be available.", + "RemediationProcedure": "Edit the Scheduler pod specification file `/etc/kubernetes/manifests/kube-scheduler.yaml` file on the Control Plane node and set the below parameter. ``` --profiling=false ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-scheduler ``` Verify that the `--profiling` argument is set to `false`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-scheduler/:https://github.com/kubernetes/community/blob/master/contributors/devel/profiling.md", + "DefaultValue": "By default, profiling is enabled." + } + ] + }, + { + "Id": "1.4.2", + "Description": "Ensure that the --bind-address argument is set to 127.0.0.1", + "Checks": [ + "scheduler_bind_address" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.4 Scheduler", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Do not bind the scheduler service to non-loopback insecure addresses.", + "RationaleStatement": "The Scheduler API service which runs on port 10251/TCP by default is used for health and metrics information and is available without authentication or encryption. As such it should only be bound to a localhost interface, to minimize the cluster's attack surface", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the Scheduler pod specification file `/etc/kubernetes/manifests/kube-scheduler.yaml` on the Control Plane node and ensure the correct value for the `--bind-address` parameter", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-scheduler ``` Verify that the `--bind-address` argument is set to 127.0.0.1", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-scheduler/", + "DefaultValue": "By default, the `--bind-address` parameter is set to 0.0.0.0" + } + ] + }, + { + "Id": "2.1", + "Description": "Ensure that the --cert-file and --key-file arguments are set as appropriate", + "Checks": [ + "etcd_tls_encryption" + ], + "Attributes": [ + { + "Section": "2 etcd", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Configure TLS encryption for the etcd service.", + "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be encrypted in transit.", + "ImpactStatement": "Client connections only over TLS would be served.", + "RemediationProcedure": "Follow the etcd service documentation and configure TLS encryption. Then, edit the etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` on the master node and set the below parameters. ``` --cert-file= --key-file= ```", + "AuditProcedure": "Run the following command on the etcd server node ``` ps -ef | grep etcd ``` Verify that the `--cert-file` and the `--key-file` arguments are set as appropriate.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd/docs/latest/op-guide/security.html:https://kubernetes.io/docs/admin/etcd/", + "DefaultValue": "By default, TLS encryption is not set." + } + ] + }, + { + "Id": "2.2", + "Description": "Ensure that the --client-cert-auth argument is set to true", + "Checks": [ + "etcd_client_cert_auth" + ], + "Attributes": [ + { + "Section": "2 etcd", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enable client authentication on etcd service.", + "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should not be available to unauthenticated clients. You should enable the client authentication via valid certificates to secure the access to the etcd service.", + "ImpactStatement": "All clients attempting to access the etcd server will require a valid client certificate.", + "RemediationProcedure": "Edit the etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` on the master node and set the below parameter. ``` --client-cert-auth=true ```", + "AuditProcedure": "Run the following command on the etcd server node: ``` ps -ef | grep etcd ``` Verify that the `--client-cert-auth` argument is set to `true`.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd/docs/latest/op-guide/security.html:https://kubernetes.io/docs/admin/etcd/:https://coreos.com/etcd/docs/latest/op-guide/configuration.html#client-cert-auth", + "DefaultValue": "By default, the etcd service can be queried by unauthenticated clients." + } + ] + }, + { + "Id": "2.3", + "Description": "Ensure that the --auto-tls argument is not set to true", + "Checks": [ + "etcd_no_auto_tls" + ], + "Attributes": [ + { + "Section": "2 etcd", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Do not use self-signed certificates for TLS.", + "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should not be available to unauthenticated clients. You should enable the client authentication via valid certificates to secure the access to the etcd service.", + "ImpactStatement": "Clients will not be able to use self-signed certificates for TLS.", + "RemediationProcedure": "Edit the etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` on the master node and either remove the `--auto-tls` parameter or set it to `false`. ``` --auto-tls=false ```", + "AuditProcedure": "Run the following command on the etcd server node: ``` ps -ef | grep etcd ``` Verify that if the `--auto-tls` argument exists, it is not set to `true`.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd/docs/latest/op-guide/security.html:https://kubernetes.io/docs/admin/etcd/:https://coreos.com/etcd/docs/latest/op-guide/configuration.html#auto-tls", + "DefaultValue": "By default, `--auto-tls` is set to `false`." + } + ] + }, + { + "Id": "2.4", + "Description": "Ensure that the --peer-cert-file and --peer-key-file arguments are set as appropriate", + "Checks": [ + "etcd_peer_tls_config" + ], + "Attributes": [ + { + "Section": "2 etcd", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "etcd should be configured to make use of TLS encryption for peer connections.", + "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be encrypted in transit and also amongst peers in the etcd clusters.", + "ImpactStatement": "etcd cluster peers would need to set up TLS for their communication.", + "RemediationProcedure": "Follow the etcd service documentation and configure peer TLS encryption as appropriate for your etcd cluster. Then, edit the etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` on the master node and set the below parameters. ``` --peer-client-file= --peer-key-file= ```", + "AuditProcedure": "Run the following command on the etcd server node: ``` ps -ef | grep etcd ``` Verify that the `--peer-cert-file` and `--peer-key-file` arguments are set as appropriate. **Note:** This recommendation is applicable only for etcd clusters. If you are using only one etcd server in your environment then this recommendation is not applicable.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd/docs/latest/op-guide/security.html:https://kubernetes.io/docs/admin/etcd/", + "DefaultValue": "**Note:** This recommendation is applicable only for etcd clusters. If you are using only one etcd server in your environment then this recommendation is not applicable. By default, peer communication over TLS is not configured." + } + ] + }, + { + "Id": "2.5", + "Description": "Ensure that the --peer-client-cert-auth argument is set to true", + "Checks": [ + "etcd_peer_client_cert_auth" + ], + "Attributes": [ + { + "Section": "2 etcd", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "etcd should be configured for peer authentication.", + "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be accessible only by authenticated etcd peers in the etcd cluster.", + "ImpactStatement": "All peers attempting to communicate with the etcd server will require a valid client certificate for authentication.", + "RemediationProcedure": "Edit the etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` on the master node and set the below parameter. ``` --peer-client-cert-auth=true ```", + "AuditProcedure": "Run the following command on the etcd server node: ``` ps -ef | grep etcd ``` Verify that the `--peer-client-cert-auth` argument is set to `true`. **Note:** This recommendation is applicable only for etcd clusters. If you are using only one etcd server in your environment then this recommendation is not applicable.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd/docs/latest/op-guide/security.html:https://kubernetes.io/docs/admin/etcd/:https://coreos.com/etcd/docs/latest/op-guide/configuration.html#peer-client-cert-auth", + "DefaultValue": "**Note:** This recommendation is applicable only for etcd clusters. If you are using only one etcd server in your environment then this recommendation is not applicable. By default, `--peer-client-cert-auth` argument is set to `false`." + } + ] + }, + { + "Id": "2.6", + "Description": "Ensure that the --peer-auto-tls argument is not set to true", + "Checks": [ + "etcd_no_peer_auto_tls" + ], + "Attributes": [ + { + "Section": "2 etcd", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Do not use automatically generated self-signed certificates for TLS connections between peers.", + "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be accessible only by authenticated etcd peers in the etcd cluster. Hence, do not use self-signed certificates for authentication.", + "ImpactStatement": "All peers attempting to communicate with the etcd server will require a valid client certificate for authentication.", + "RemediationProcedure": "Edit the etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` on the master node and either remove the `--peer-auto-tls` parameter or set it to `false`. ``` --peer-auto-tls=false ```", + "AuditProcedure": "Run the following command on the etcd server node: ``` ps -ef | grep etcd ``` Verify that if the `--peer-auto-tls` argument exists, it is not set to `true`. **Note:** This recommendation is applicable only for etcd clusters. If you are using only one etcd server in your environment then this recommendation is not applicable.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd/docs/latest/op-guide/security.html:https://kubernetes.io/docs/admin/etcd/:https://coreos.com/etcd/docs/latest/op-guide/configuration.html#peer-auto-tls", + "DefaultValue": "**Note:** This recommendation is applicable only for etcd clusters. If you are using only one etcd server in your environment then this recommendation is not applicable. By default, `--peer-auto-tls` argument is set to `false`." + } + ] + }, + { + "Id": "2.7", + "Description": "Ensure that a unique Certificate Authority is used for etcd", + "Checks": [ + "etcd_unique_ca" + ], + "Attributes": [ + { + "Section": "2 etcd", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Use a different certificate authority for etcd from the one used for Kubernetes.", + "RationaleStatement": "etcd is a highly available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. Its access should be restricted to specifically designated clients and peers only. Authentication to etcd is based on whether the certificate presented was issued by a trusted certificate authority. There is no checking of certificate attributes such as common name or subject alternative name. As such, if any attackers were able to gain access to any certificate issued by the trusted certificate authority, they would be able to gain full access to the etcd database.", + "ImpactStatement": "Additional management of the certificates and keys for the dedicated certificate authority will be required.", + "RemediationProcedure": "Follow the etcd documentation and create a dedicated certificate authority setup for the etcd service. Then, edit the etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` on the master node and set the below parameter. ``` --trusted-ca-file= ```", + "AuditProcedure": "Review the CA used by the etcd environment and ensure that it does not match the CA certificate file used for the management of the overall Kubernetes cluster. Run the following command on the master node: ``` ps -ef | grep etcd ``` Note the file referenced by the `--trusted-ca-file` argument. Run the following command on the master node: ``` ps -ef | grep apiserver ``` Verify that the file referenced by the `--client-ca-file` for apiserver is different from the `--trusted-ca-file` used by etcd.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd/docs/latest/op-guide/security.html", + "DefaultValue": "By default, no etcd certificate is created and used." + } + ] + }, + { + "Id": "3.1.1", + "Description": "Client certificate authentication should not be used for users", + "Checks": [], + "Attributes": [ + { + "Section": "3 Control Plane Configuration", + "SubSection": "3.1 Authentication and Authorization", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Kubernetes provides the option to use client certificates for user authentication. However as there is no way to revoke these certificates when a user leaves an organization or loses their credential, they are not suitable for this purpose. It is not possible to fully disable client certificate use within a cluster as it is used for component to component authentication.", + "RationaleStatement": "With any authentication mechanism the ability to revoke credentials if they are compromised or no longer required, is a key control. Kubernetes client certificate authentication does not allow for this due to a lack of support for certificate revocation.", + "ImpactStatement": "External mechanisms for authentication generally require additional software to be deployed.", + "RemediationProcedure": "Alternative mechanisms provided by Kubernetes such as the use of OIDC should be implemented in place of client certificates.", + "AuditProcedure": "Review user access to the cluster and ensure that users are not making use of Kubernetes client certificate authentication.", + "AdditionalInformation": "The lack of certificate revocation was flagged up as a high risk issue in the recent Kubernetes security audit. Without this feature, client certificate authentication is not suitable for end users.", + "References": "", + "DefaultValue": "Client certificate authentication is enabled by default." + } + ] + }, + { + "Id": "3.1.2", + "Description": "Service account token authentication should not be used for users", + "Checks": [], + "Attributes": [ + { + "Section": "3 Control Plane Configuration", + "SubSection": "3.1 Authentication and Authorization", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Kubernetes provides service account tokens which are intended for use by workloads running in the Kubernetes cluster, for authentication to the API server. These tokens are not designed for use by end-users and do not provide for features such as revocation or expiry, making them insecure. A newer version of the feature (Bound service account token volumes) does introduce expiry but still does not allow for specific revocation.", + "RationaleStatement": "With any authentication mechanism the ability to revoke credentials if they are compromised or no longer required, is a key control. Service account token authentication does not allow for this due to the use of JWT tokens as an underlying technology.", + "ImpactStatement": "External mechanisms for authentication generally require additional software to be deployed.", + "RemediationProcedure": "Alternative mechanisms provided by Kubernetes such as the use of OIDC should be implemented in place of service account tokens.", + "AuditProcedure": "Review user access to the cluster and ensure that users are not making use of service account token authentication.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "Service account token authentication is enabled by default." + } + ] + }, + { + "Id": "3.1.3", + "Description": "Bootstrap token authentication should not be used for users", + "Checks": [], + "Attributes": [ + { + "Section": "3 Control Plane Configuration", + "SubSection": "3.1 Authentication and Authorization", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Kubernetes provides bootstrap tokens which are intended for use by new nodes joining the cluster These tokens are not designed for use by end-users they are specifically designed for the purpose of bootstrapping new nodes and not for general authentication", + "RationaleStatement": "Bootstrap tokens are not intended for use as a general authentication mechanism and impose constraints on user and group naming that do not facilitate good RBAC design. They also cannot be used with MFA resulting in a weak authentication mechanism being available.", + "ImpactStatement": "External mechanisms for authentication generally require additional software to be deployed.", + "RemediationProcedure": "Alternative mechanisms provided by Kubernetes such as the use of OIDC should be implemented in place of bootstrap tokens.", + "AuditProcedure": "Review user access to the cluster and ensure that users are not making use of bootstrap token authentication.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "Bootstrap token authentication is not enabled by default and requires an API server parameter to be set." + } + ] + }, + { + "Id": "3.2.1", + "Description": "Ensure that a minimal audit policy is created", + "Checks": [], + "Attributes": [ + { + "Section": "3 Control Plane Configuration", + "SubSection": "3.2 Logging", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Kubernetes can audit the details of requests made to the API server. The `--audit-policy-file` flag must be set for this logging to be enabled.", + "RationaleStatement": "Logging is an important detective control for all systems, to detect potential unauthorised access.", + "ImpactStatement": "Audit logs will be created on the master nodes, which will consume disk space. Care should be taken to avoid generating too large volumes of log information as this could impact the available of the cluster nodes.", + "RemediationProcedure": "Create an audit policy file for your cluster.", + "AuditProcedure": "Run the following command on one of the cluster master nodes: ``` ps -ef | grep kube-apiserver ``` Verify that the `--audit-policy-file` is set. Review the contents of the file specified and ensure that it contains a valid audit policy.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tasks/debug-application-cluster/audit/", + "DefaultValue": "Unless the `--audit-policy-file` flag is specified, no auditing will be carried out." + } + ] + }, + { + "Id": "3.2.2", + "Description": "Ensure that the audit policy covers key security concerns", + "Checks": [], + "Attributes": [ + { + "Section": "3 Control Plane Configuration", + "SubSection": "3.2 Logging", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that the audit policy created for the cluster covers key security concerns.", + "RationaleStatement": "Security audit logs should cover access and modification of key resources in the cluster, to enable them to form an effective part of a security environment.", + "ImpactStatement": "Increasing audit logging will consume resources on the nodes or other log destination.", + "RemediationProcedure": "Consider modification of the audit policy in use on the cluster to include these items, at a minimum.", + "AuditProcedure": "Review the audit policy provided for the cluster and ensure that it covers at least the following areas :- - Access to Secrets managed by the cluster. Care should be taken to only log Metadata for requests to Secrets, ConfigMaps, and TokenReviews, in order to avoid the risk of logging sensitive data. - Modification of `pod` and `deployment` objects. - Use of `pods/exec`, `pods/portforward`, `pods/proxy` and `services/proxy`. - Use of the `CertificateSigningRequest` API which allows for creation of new credentials. - Use of the Token sub-resource of `ServiceAccount` objects which allows for creation of new credentials. For most requests, minimally logging at the Metadata level is recommended (the most basic level of logging). Exceptions should be created in the audit logging policy to ensure that system operations (e.g. the Kubelet creating new credentials) are not logged, to reduce operational load and the risk of false positives.", + "AdditionalInformation": "", + "References": "https://github.com/k8scop/k8s-security-dashboard/blob/master/configs/kubernetes/adv-audit.yaml:https://kubernetes.io/docs/tasks/debug-application-cluster/audit/#audit-policy:https://github.com/kubernetes/kubernetes/blob/master/cluster/gce/gci/configure-helper.sh#L735", + "DefaultValue": "By default Kubernetes clusters do not log audit information." + } + ] + }, + { + "Id": "4.1.1", + "Description": "Ensure that the kubelet service file permissions are set to 600 or more restrictive", + "Checks": [ + "kubelet_service_file_permissions" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `kubelet` service file has permissions of `600` or more restrictive.", + "RationaleStatement": "The `kubelet` service file controls various parameters that set the behavior of the `kubelet` service in the worker node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the each worker node. For example, ``` chmod 600 /etc/systemd/system/kubelet.service.d/kubeadm.conf ```", + "AuditProcedure": "Automated AAC auditing has been modified to allow CIS-CAT to input a variable for the / of the kubelet service config file. Please set $kubelet_service_config= based on the file location on your system for example: ``` export kubelet_service_config=/etc/systemd/system/kubelet.service.d/kubeadm.conf ``` To perform the audit manually: Run the below command (based on the file location on your system) on the each worker node. For example, ``` stat -c %a /etc/systemd/system/kubelet.service.d/10-kubeadm.conf ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/:https://kubernetes.io/docs/setup/independent/create-cluster-kubeadm/#44-joining-your-nodes:https://kubernetes.io/docs/admin/kubeadm/#kubelet-drop-in", + "DefaultValue": "By default, the `kubelet` service file has permissions of `640`." + } + ] + }, + { + "Id": "4.1.2", + "Description": "Ensure that the kubelet service file ownership is set to root:root", + "Checks": [ + "kubelet_service_file_ownership_root" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `kubelet` service file ownership is set to `root:root`.", + "RationaleStatement": "The `kubelet` service file controls various parameters that set the behavior of the `kubelet` service in the worker node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the each worker node. For example, ``` chown root:root /etc/systemd/system/kubelet.service.d/kubeadm.conf ```", + "AuditProcedure": "Automated AAC auditing has been modified to allow CIS-CAT to input a variable for the / of the kubelet service config file. Please set $kubelet_service_config= based on the file location on your system for example: ``` export kubelet_service_config=/etc/systemd/system/kubelet.service.d/kubeadm.conf ``` To perform the audit manually: Run the below command (based on the file location on your system) on the each worker node. For example, ``` stat -c %U:%G /etc/systemd/system/kubelet.service.d/10-kubeadm.conf ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/:https://kubernetes.io/docs/setup/independent/create-cluster-kubeadm/#44-joining-your-nodes:https://kubernetes.io/docs/admin/kubeadm/#kubelet-drop-in", + "DefaultValue": "By default, `kubelet` service file ownership is set to `root:root`." + } + ] + }, + { + "Id": "4.1.3", + "Description": "If proxy kubeconfig file exists ensure permissions are set to 600 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "If `kube-proxy` is running, and if it is using a file-based kubeconfig file, ensure that the proxy kubeconfig file has permissions of `600` or more restrictive.", + "RationaleStatement": "The `kube-proxy` kubeconfig file controls various parameters of the `kube-proxy` service in the worker node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system. It is possible to run `kube-proxy` with the kubeconfig parameters configured as a Kubernetes ConfigMap instead of a file. In this case, there is no proxy kubeconfig file.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the each worker node. For example, ``` chmod 600 ```", + "AuditProcedure": "Find the kubeconfig file being used by `kube-proxy` by running the following command: ``` ps -ef | grep kube-proxy ``` If `kube-proxy` is running, get the kubeconfig file location from the `--kubeconfig` parameter. To perform the audit: Run the below command (based on the file location on your system) on the each worker node. For example, ``` stat -c %a ``` Verify that a file is specified and it exists with permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-proxy/", + "DefaultValue": "By default, proxy file has permissions of `640`." + } + ] + }, + { + "Id": "4.1.4", + "Description": "If proxy kubeconfig file exists ensure ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "If `kube-proxy` is running, ensure that the file ownership of its kubeconfig file is set to `root:root`.", + "RationaleStatement": "The kubeconfig file for `kube-proxy` controls various parameters for the `kube-proxy` service in the worker node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the each worker node. For example, ``` chown root:root ```", + "AuditProcedure": "Find the kubeconfig file being used by `kube-proxy` by running the following command: ``` ps -ef | grep kube-proxy ``` If `kube-proxy` is running, get the kubeconfig file location from the `--kubeconfig` parameter. To perform the audit: Run the below command (based on the file location on your system) on the each worker node. For example, ``` stat -c %U:%G ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-proxy/", + "DefaultValue": "By default, `proxy` file ownership is set to `root:root`." + } + ] + }, + { + "Id": "4.1.5", + "Description": "Ensure that the --kubeconfig kubelet.conf file permissions are set to 600 or more restrictive", + "Checks": [ + "kubelet_conf_file_permissions" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `kubelet.conf` file has permissions of `600` or more restrictive.", + "RationaleStatement": "The `kubelet.conf` file is the kubeconfig file for the node, and controls various parameters that set the behavior and identity of the worker node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the each worker node. For example, ``` chmod 600 /etc/kubernetes/kubelet.conf ```", + "AuditProcedure": "Automated AAC auditing has been modified to allow CIS-CAT to input a variable for the / of the kubelet config file. Please set $kubelet_config= based on the file location on your system for example: ``` export kubelet_config=/etc/kubernetes/kubelet.conf ``` To perform the audit manually: Run the below command (based on the file location on your system) on the each worker node. For example, ``` stat -c %a /etc/kubernetes/kubelet.conf ``` Verify that the ownership is set to `root:root`.Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/", + "DefaultValue": "By default, `kubelet.conf` file has permissions of `600`." + } + ] + }, + { + "Id": "4.1.6", + "Description": "Ensure that the --kubeconfig kubelet.conf file ownership is set to root:root", + "Checks": [ + "kubelet_conf_file_ownership" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `kubelet.conf` file ownership is set to `root:root`.", + "RationaleStatement": "The `kubelet.conf` file is the kubeconfig file for the node, and controls various parameters that set the behavior and identity of the worker node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the each worker node. For example, ``` chown root:root /etc/kubernetes/kubelet.conf ```", + "AuditProcedure": "Automated AAC auditing has been modified to allow CIS-CAT to input a variable for the / of the kubelet config file. Please set $kubelet_config= based on the file location on your system for example: ``` export kubelet_config=/etc/kubernetes/kubelet.conf ``` To perform the audit manually: Run the below command (based on the file location on your system) on the each worker node. For example, ``` stat -c %U:%G /etc/kubernetes/kubelet.conf ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/", + "DefaultValue": "By default, `kubelet.conf` file ownership is set to `root:root`." + } + ] + }, + { + "Id": "4.1.7", + "Description": "Ensure that the certificate authorities file permissions are set to 644 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that the certificate authorities file has permissions of `644` or more restrictive.", + "RationaleStatement": "The certificate authorities file controls the authorities used to validate API requests. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the following command to modify the file permissions of the `--client-ca-file` ``` chmod 644 ```", + "AuditProcedure": "Run the following command: ``` ps -ef | grep kubelet ``` Find the file specified by the `--client-ca-file` argument. Run the following command: ``` stat -c %a ``` Verify that the permissions are `644` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/authentication/#x509-client-certs", + "DefaultValue": "By default no `--client-ca-file` is specified." + } + ] + }, + { + "Id": "4.1.8", + "Description": "Ensure that the client certificate authorities file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that the certificate authorities file ownership is set to `root:root`.", + "RationaleStatement": "The certificate authorities file controls the authorities used to validate API requests. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the following command to modify the ownership of the `--client-ca-file`. ``` chown root:root ```", + "AuditProcedure": "Run the following command: ``` ps -ef | grep kubelet ``` Find the file specified by the `--client-ca-file` argument. Run the following command: ``` stat -c %U:%G ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/authentication/#x509-client-certs", + "DefaultValue": "By default no `--client-ca-file` is specified." + } + ] + }, + { + "Id": "4.1.9", + "Description": "If the kubelet config.yaml configuration file is being used validate permissions set to 600 or more restrictive", + "Checks": [ + "kubelet_config_yaml_permissions" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that if the kubelet refers to a configuration file with the `--config` argument, that file has permissions of 600 or more restrictive.", + "RationaleStatement": "The kubelet reads various parameters, including security settings, from a config file specified by the `--config` argument. If this file is specified you should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the following command (using the config file location identified in the Audit step) ``` chmod 600 /var/lib/kubelet/config.yaml ```", + "AuditProcedure": "Automated AAC auditing has been modified to allow CIS-CAT to input a variable for the / of the kubelet config yaml file. Please set $kubelet_config_yaml= based on the file location on your system for example: ``` export kubelet_config_yaml=/var/lib/kubelet/config.yaml ``` To perform the audit manually: Run the below command (based on the file location on your system) on the each worker node. For example, ``` stat -c %a /var/lib/kubelet/config.yaml ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/", + "DefaultValue": "By default, the /var/lib/kubelet/config.yaml file as set up by `kubeadm` has permissions of 600." + } + ] + }, + { + "Id": "4.1.10", + "Description": "If the kubelet config.yaml configuration file is being used validate file ownership is set to root:root", + "Checks": [ + "kubelet_config_yaml_ownership" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that if the kubelet refers to a configuration file with the `--config` argument, that file is owned by root:root.", + "RationaleStatement": "The kubelet reads various parameters, including security settings, from a config file specified by the `--config` argument. If this file is specified you should restrict its file permissions to maintain the integrity of the file. The file should be owned by root:root.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the following command (using the config file location identied in the Audit step) ``` chown root:root /etc/kubernetes/kubelet.conf ```", + "AuditProcedure": "Automated AAC auditing has been modified to allow CIS-CAT to input a variable for the / of the kubelet config yaml file. Please set $kubelet_config_yaml= based on the file location on your system for example: ``` export kubelet_config_yaml=/var/lib/kubelet/config.yaml ``` To perform the audit manually: Run the below command (based on the file location on your system) on the each worker node. For example, ``` stat -c %U:%G /var/lib/kubelet/config.yaml ```Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/", + "DefaultValue": "By default, `/var/lib/kubelet/config.yaml` file as set up by `kubeadm` is owned by `root:root`." + } + ] + }, + { + "Id": "4.2.1", + "Description": "Ensure that the --anonymous-auth argument is set to false", + "Checks": [ + "kubelet_disable_anonymous_auth" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Disable anonymous requests to the Kubelet server.", + "RationaleStatement": "When enabled, requests that are not rejected by other configured authentication methods are treated as anonymous requests. These requests are then served by the Kubelet server. You should rely on authentication to authorize access and disallow anonymous requests.", + "ImpactStatement": "Anonymous requests will be rejected.", + "RemediationProcedure": "If using a Kubelet config file, edit the file to set `authentication: anonymous: enabled` to `false`. If using executable arguments, edit the kubelet service file `/etc/kubernetes/kubelet.conf` on each worker node and set the below parameter in `KUBELET_SYSTEM_PODS_ARGS` variable. ``` --anonymous-auth=false ``` Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "If using a Kubelet configuration file, check that there is an entry for `authentication: anonymous: enabled` set to `false`. Run the following command on each node: ``` ps -ef | grep kubelet ``` Verify that the `--anonymous-auth` argument is set to `false`. This executable argument may be omitted, provided there is a corresponding entry set to `false` in the Kubelet config file.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/:https://kubernetes.io/docs/admin/kubelet-authentication-authorization/#kubelet-authentication", + "DefaultValue": "By default, anonymous access is enabled." + } + ] + }, + { + "Id": "4.2.2", + "Description": "Ensure that the --authorization-mode argument is not set to AlwaysAllow", + "Checks": [ + "kubelet_authorization_mode" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Do not allow all requests. Enable explicit authorization.", + "RationaleStatement": "Kubelets, by default, allow all authenticated requests (even anonymous ones) without needing explicit authorization checks from the apiserver. You should restrict this behavior and only allow explicitly authorized requests.", + "ImpactStatement": "Unauthorized requests will be denied.", + "RemediationProcedure": "If using a Kubelet config file, edit the file to set `authorization: mode` to `Webhook`. If using executable arguments, edit the kubelet service file `/etc/kubernetes/kubelet.conf` on each worker node and set the below parameter in `KUBELET_AUTHZ_ARGS` variable. ``` --authorization-mode=Webhook ``` Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Run the following command on each node: ``` ps -ef | grep kubelet ``` If the `--authorization-mode` argument is present check that it is not set to `AlwaysAllow`. If it is not present check that there is a Kubelet config file specified by `--config`, and that file sets `authorization: mode` to something other than `AlwaysAllow`. It is also possible to review the running configuration of a Kubelet via the `/configz` endpoint on the Kubelet API port (typically `10250/TCP`). Accessing these with appropriate credentials will provide details of the Kubelet's configuration.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/:https://kubernetes.io/docs/admin/kubelet-authentication-authorization/#kubelet-authentication", + "DefaultValue": "By default, `--authorization-mode` argument is set to `AlwaysAllow`." + } + ] + }, + { + "Id": "4.2.3", + "Description": "Ensure that the --client-ca-file argument is set as appropriate", + "Checks": [ + "kubelet_client_ca_file_set" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enable Kubelet authentication using certificates.", + "RationaleStatement": "The connections from the apiserver to the kubelet are used for fetching logs for pods, attaching (through kubectl) to running pods, and using the kubelet’s port-forwarding functionality. These connections terminate at the kubelet’s HTTPS endpoint. By default, the apiserver does not verify the kubelet’s serving certificate, which makes the connection subject to man-in-the-middle attacks, and unsafe to run over untrusted and/or public networks. Enabling Kubelet certificate authentication ensures that the apiserver could authenticate the Kubelet before submitting any requests.", + "ImpactStatement": "You require TLS to be configured on apiserver as well as kubelets.", + "RemediationProcedure": "If using a Kubelet config file, edit the file to set `authentication: x509: clientCAFile` to the location of the client CA file. If using command line arguments, edit the kubelet service file `/etc/kubernetes/kubelet.conf` on each worker node and set the below parameter in `KUBELET_AUTHZ_ARGS` variable. ``` --client-ca-file= ``` Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Run the following command on each node: ``` ps -ef | grep kubelet ``` Verify that the `--client-ca-file` argument exists and is set to the location of the client certificate authority file. If the `--client-ca-file` argument is not present, check that there is a Kubelet config file specified by `--config`, and that the file sets `authentication: x509: clientCAFile` to the location of the client certificate authority file.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/:https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet-authentication-authorization/", + "DefaultValue": "By default, `--client-ca-file` argument is not set." + } + ] + }, + { + "Id": "4.2.4", + "Description": "Verify that if defined, readOnlyPort is set to 0", + "Checks": [ + "kubelet_disable_read_only_port" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Disable the read-only port.", + "RationaleStatement": "The Kubelet process provides a read-only API in addition to the main Kubelet API. Unauthenticated access is provided to this read-only API which could possibly retrieve potentially sensitive information about the cluster.", + "ImpactStatement": "Removal of the read-only port will require that any service which made use of it will need to be re-configured to use the main Kubelet API.", + "RemediationProcedure": "If using a Kubelet config file, edit the file to set `readOnlyPort` to `0`. If using command line arguments, edit the kubelet service file `/etc/kubernetes/kubelet.conf` on each worker node and set the below parameter in `KUBELET_SYSTEM_PODS_ARGS` variable. ``` --read-only-port=0 ``` Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Run the following command on each node: ``` ps -ef | grep kubelet ``` Verify that the `--read-only-port` argument exists and is set to `0`. If the `--read-only-port` argument is not present, check that there is a Kubelet config file specified by `--config`. Check that if there is a `readOnlyPort` entry in the file, it is set to `0`.", + "AdditionalInformation": "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/", + "References": "https://kubernetes.io/docs/admin/kubelet/:https://github.com/kubernetes/kubernetes/blob/6cedc0853faa118df0ba3d41b48b993422ad3df6/staging/src/k8s.io/kubelet/config/v1beta1/types.go#L142", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.2.5", + "Description": "Ensure that the --streaming-connection-idle-timeout argument is not set to 0", + "Checks": [ + "kubelet_streaming_connection_timeout" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not disable timeouts on streaming connections.", + "RationaleStatement": "Setting idle timeouts ensures that you are protected against Denial-of-Service attacks, inactive connections and running out of ephemeral ports. **Note:** By default, `--streaming-connection-idle-timeout` is set to 4 hours which might be too high for your environment. Setting this as appropriate would additionally ensure that such streaming connections are timed out after serving legitimate use cases.", + "ImpactStatement": "Long-lived connections could be interrupted.", + "RemediationProcedure": "If using a Kubelet config file, edit the file to set `streamingConnectionIdleTimeout` to a value other than 0. If using command line arguments, edit the kubelet service file `/etc/kubernetes/kubelet.conf` on each worker node and set the below parameter in `KUBELET_SYSTEM_PODS_ARGS` variable. ``` --streaming-connection-idle-timeout=5m ``` Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Run the following command on each node: ``` ps -ef | grep kubelet ``` Verify that the `--streaming-connection-idle-timeout` argument is not set to `0`. If the argument is not present, and there is a Kubelet config file specified by `--config`, check that it does not set `streamingConnectionIdleTimeout` to 0.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/:https://github.com/kubernetes/kubernetes/pull/18552", + "DefaultValue": "By default, `--streaming-connection-idle-timeout` is set to 4 hours." + } + ] + }, + { + "Id": "4.2.6", + "Description": "Ensure that the --make-iptables-util-chains argument is set to true", + "Checks": [ + "kubelet_manage_iptables" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Allow Kubelet to manage iptables.", + "RationaleStatement": "Kubelets can automatically manage the required changes to iptables based on how you choose your networking options for the pods. It is recommended to let kubelets manage the changes to iptables. This ensures that the iptables configuration remains in sync with pods networking configuration. Manually configuring iptables with dynamic pod network configuration changes might hamper the communication between pods/containers and to the outside world. You might have iptables rules too restrictive or too open.", + "ImpactStatement": "Kubelet would manage the iptables on the system and keep it in sync. If you are using any other iptables management solution, then there might be some conflicts.", + "RemediationProcedure": "If using a Kubelet config file, edit the file to set `makeIPTablesUtilChains: true`. If using command line arguments, edit the kubelet service file `/etc/kubernetes/kubelet.conf` on each worker node and remove the `--make-iptables-util-chains` argument from the `KUBELET_SYSTEM_PODS_ARGS` variable. Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Run the following command on each node: ``` ps -ef | grep kubelet ``` Verify that if the `--make-iptables-util-chains` argument exists then it is set to `true`. If the `--make-iptables-util-chains` argument does not exist, and there is a Kubelet config file specified by `--config`, verify that the file does not set `makeIPTablesUtilChains` to `false`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/", + "DefaultValue": "By default, `--make-iptables-util-chains` argument is set to `true`." + } + ] + }, + { + "Id": "4.2.7", + "Description": "Ensure that the --hostname-override argument is not set", + "Checks": [], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not override node hostnames.", + "RationaleStatement": "Overriding hostnames could potentially break TLS setup between the kubelet and the apiserver. Additionally, with overridden hostnames, it becomes increasingly difficult to associate logs with a particular node and process them for security analytics. Hence, you should setup your kubelet nodes with resolvable FQDNs and avoid overriding the hostnames with IPs.", + "ImpactStatement": "Some cloud providers may require this flag to ensure that hostname matches names issued by the cloud provider. In these environments, this recommendation should not apply.", + "RemediationProcedure": "Edit the kubelet service file `/etc/systemd/system/kubelet.service.d/10-kubeadm.conf` on each worker node and remove the `--hostname-override` argument from the `KUBELET_SYSTEM_PODS_ARGS` variable. Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Run the following command on each node: ``` ps -ef | grep kubelet ``` Verify that `--hostname-override` argument does not exist. **Note** This setting is not configurable via the Kubelet config file.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/:https://github.com/kubernetes/kubernetes/issues/22063", + "DefaultValue": "By default, `--hostname-override` argument is not set." + } + ] + }, + { + "Id": "4.2.8", + "Description": "Ensure that the eventRecordQPS argument is set to a level which ensures appropriate event capture", + "Checks": [ + "kubelet_event_record_qps" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Security relevant information should be captured. The eventRecordQPS on the Kubelet configuration can be used to limit the rate at which events are gathered and sets the maximum event creations per second. Setting this too low could result in relevant events not being logged, however the unlimited setting of `0` could result in a denial of service on the kubelet.", + "RationaleStatement": "It is important to capture all events and not restrict event creation. Events are an important source of security information and analytics that ensure that your environment is consistently monitored using the event data.", + "ImpactStatement": "Setting this parameter to `0` could result in a denial of service condition due to excessive events being created. The cluster's event processing and storage systems should be scaled to handle expected event loads.", + "RemediationProcedure": "If using a Kubelet config file, edit the file to set `eventRecordQPS:` to an appropriate level. If using command line arguments, edit the kubelet service file `/etc/systemd/system/kubelet.service.d/10-kubeadm.conf` on each worker node and set the below parameter in `KUBELET_ARGS` variable. Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Run the following command on each node: ``` sudo grep eventRecordQPS /etc/systemd/system/kubelet.service.d/10-kubeadm.conf ``` or If using command line arguments, kubelet service file is located /etc/systemd/system/kubelet.service.d/10-kubelet-args.conf ``` sudo grep eventRecordQPS /etc/systemd/system/kubelet.service.d/10-kubelet-args.conf ``` Review the value set for the argument and determine whether this has been set to an appropriate level for the cluster. If the argument does not exist, check that there is a Kubelet config file specified by `--config` and review the value in this location. If using command line arguments", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/:https://github.com/kubernetes/kubernetes/blob/master/pkg/kubelet/apis/kubeletconfig/v1beta1/types.go", + "DefaultValue": "By default, eventRecordQPS argument is set to `5`." + } + ] + }, + { + "Id": "4.2.9", + "Description": "Ensure that the --tls-cert-file and --tls-private-key-file arguments are set as appropriate", + "Checks": [ + "kubelet_tls_cert_and_key" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Setup TLS connection on the Kubelets.", + "RationaleStatement": "The connections from the apiserver to the kubelet are used for fetching logs for pods, attaching (through kubectl) to running pods, and using the kubelet’s port-forwarding functionality. These connections terminate at the kubelet’s HTTPS endpoint. By default, the apiserver does not verify the kubelet’s serving certificate, which makes the connection subject to man-in-the-middle attacks, and unsafe to run over untrusted and/or public networks.", + "ImpactStatement": "", + "RemediationProcedure": "If using a Kubelet config file, edit the file to set tlsCertFile to the location of the certificate file to use to identify this Kubelet, and tlsPrivateKeyFile to the location of the corresponding private key file. If using command line arguments, edit the kubelet service file /etc/kubernetes/kubelet.conf on each worker node and set the below parameters in KUBELET_CERTIFICATE_ARGS variable. --tls-cert-file= --tls-private-key-file= Based on your system, restart the kubelet service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Run the following command on each node: ``` ps -ef | grep kubelet ``` Verify that the --tls-cert-file and --tls-private-key-file arguments exist and they are set as appropriate. If these arguments are not present, check that there is a Kubelet config specified by --config and that it contains appropriate settings for tlsCertFile and tlsPrivateKeyFile.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.2.10", + "Description": "Ensure that the --rotate-certificates argument is not set to false", + "Checks": [ + "kubelet_rotate_certificates" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enable kubelet client certificate rotation.", + "RationaleStatement": "The `--rotate-certificates` setting causes the kubelet to rotate its client certificates by creating new CSRs as its existing credentials expire. This automated periodic rotation ensures that the there is no downtime due to expired certificates and thus addressing availability in the CIA security triad. **Note:** This recommendation only applies if you let kubelets get their certificates from the API server. In case your kubelet certificates come from an outside authority/tool (e.g. Vault) then you need to take care of rotation yourself. **Note:** This feature also require the `RotateKubeletClientCertificate` feature gate to be enabled (which is the default since Kubernetes v1.7)", + "ImpactStatement": "None", + "RemediationProcedure": "If using a Kubelet config file, edit the file to add the line `rotateCertificates: true` or remove it altogether to use the default value. If using command line arguments, edit the kubelet service file `/etc/kubernetes/kubelet.conf` on each worker node and remove `--rotate-certificates=false` argument from the `KUBELET_CERTIFICATE_ARGS` variable or set --rotate-certificates=true . Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Run the following command on each node: ``` ps -ef | grep kubelet ``` Verify that the `RotateKubeletServerCertificate` argument is not present, or is set to `true`. If the RotateKubeletServerCertificate argument is not present, verify that if there is a Kubelet config file specified by `--config`, that file does not contain `RotateKubeletServerCertificate: false`.", + "AdditionalInformation": "", + "References": "https://github.com/kubernetes/kubernetes/pull/41912:https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet-tls-bootstrapping/#kubelet-configuration:https://kubernetes.io/docs/imported/release/notes/:https://kubernetes.io/docs/reference/command-line-tools-reference/feature-gates/", + "DefaultValue": "By default, kubelet client certificate rotation is enabled." + } + ] + }, + { + "Id": "4.2.11", + "Description": "Verify that the RotateKubeletServerCertificate argument is set to true", + "Checks": [], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Enable kubelet server certificate rotation.", + "RationaleStatement": "`RotateKubeletServerCertificate` causes the kubelet to both request a serving certificate after bootstrapping its client credentials and rotate the certificate as its existing credentials expire. This automated periodic rotation ensures that the there are no downtimes due to expired certificates and thus addressing availability in the CIA security triad. Note: This recommendation only applies if you let kubelets get their certificates from the API server. In case your kubelet certificates come from an outside authority/tool (e.g. Vault) then you need to take care of rotation yourself.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the kubelet service file `/etc/kubernetes/kubelet.conf` on each worker node and set the below parameter in `KUBELET_CERTIFICATE_ARGS` variable. ``` --feature-gates=RotateKubeletServerCertificate=true ``` Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Ignore this check if serverTLSBootstrap is true in the kubelet config file or if the --rotate-server-certificates parameter is set on kubelet Run the following command on each node: ``` ps -ef | grep kubelet ``` Verify that `RotateKubeletServerCertificate` argument exists and is set to `true`.", + "AdditionalInformation": "", + "References": "https://github.com/kubernetes/kubernetes/pull/45059:https://kubernetes.io/docs/admin/kubelet-tls-bootstrapping/#kubelet-configuration", + "DefaultValue": "By default, kubelet server certificate rotation is enabled." + } + ] + }, + { + "Id": "4.2.12", + "Description": "Ensure that the Kubelet only makes use of Strong Cryptographic Ciphers", + "Checks": [ + "kubelet_strong_ciphers_only" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that the Kubelet is configured to only use strong cryptographic ciphers.", + "RationaleStatement": "TLS ciphers have had a number of known vulnerabilities and weaknesses, which can reduce the protection provided by them. By default Kubernetes supports a number of TLS ciphersuites including some that have security concerns, weakening the protection provided.", + "ImpactStatement": "Kubelet clients that cannot support modern cryptographic ciphers will not be able to make connections to the Kubelet API.", + "RemediationProcedure": "If using a Kubelet config file, edit the file to set `tlsCipherSuites:` to `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_128_GCM_SHA256` or to a subset of these values. If using executable arguments, edit the kubelet service file `/etc/kubernetes/kubelet.conf` on each worker node and set the `--tls-cipher-suites` parameter as follows, or to a subset of these values. ``` --tls-cipher-suites=TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_128_GCM_SHA256 ``` Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "The set of cryptographic ciphers currently considered secure is the following: - `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256` - `TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256` - `TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305` - `TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384` - `TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305` - `TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384` - `TLS_RSA_WITH_AES_256_GCM_SHA384` - `TLS_RSA_WITH_AES_128_GCM_SHA256` Run the following command on each node: ``` ps -ef | grep kubelet ``` If the `--tls-cipher-suites` argument is present, ensure it only contains values included in this set. If it is not present check that there is a Kubelet config file specified by `--config`, and that file sets `tlsCipherSuites:` to only include values from this set.", + "AdditionalInformation": "The list chosen above should be fine for modern clients. It's essentially the list from the Mozilla Modern cipher option with the ciphersuites supporting CBC mode removed, as CBC has traditionally had a lot of issues", + "References": "", + "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers" + } + ], + "ConfigRequirements": [ + { + "Check": "kubelet_strong_ciphers_only", + "ConfigKey": "kubelet_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_128_GCM_SHA256" + ] + } + ] + }, + { + "Id": "4.2.13", + "Description": "Ensure that a limit is set on pod PIDs", + "Checks": [], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that the Kubelet sets limits on the number of PIDs that can be created by pods running on the node.", + "RationaleStatement": "By default pods running in a cluster can consume any number of PIDs, potentially exhausting the resources available on the node. Setting an appropriate limit reduces the risk of a denial of service attack on cluster nodes.", + "ImpactStatement": "Setting this value will restrict the number of processes per pod. If this limit is lower than the number of PIDs required by a pod it will not operate.", + "RemediationProcedure": "Decide on an appropriate level for this parameter and set it, either via the `--pod-max-pids` command line parameter or the `PodPidsLimit` configuration file setting.", + "AuditProcedure": "Review the Kubelet's start-up parameters for the value of `--pod-max-pids`, and check the Kubelet configuration file for the `PodPidsLimit` . If neither of these values is set, then there is no limit in place.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/policy/pid-limiting/#pod-pid-limits", + "DefaultValue": "By default the number of PIDs is not limited." + } + ] + }, + { + "Id": "4.2.14", + "Description": "Ensure that the --seccomp-default parameter is set to true", + "Checks": [], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that the Kubelet enforces the use of the RuntimeDefault seccomp profile", + "RationaleStatement": "By default, Kubernetes disables the seccomp profile which ships with most container runtimes. Setting this parameter will ensure workloads running on the node are protected by the runtime's seccomp profile.", + "ImpactStatement": "Setting this will remove some rights from pods running on the node.", + "RemediationProcedure": "Set the parameter, either via the `--seccomp-default` command line parameter or the `seccompDefault` configuration file setting.", + "AuditProcedure": "Review the Kubelet's start-up parameters for the value of `--seccomp-default`, and check the Kubelet configuration file for the `seccompDefault` . If neither of these values is set, then the seccomp profile is not in use.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tutorials/security/seccomp/#enable-the-use-of-runtimedefault-as-the-default-seccomp-profile-for-all-workloads", + "DefaultValue": "By default the seccomp profile is not enabled." + } + ] + }, + { + "Id": "4.3.1", + "Description": "Ensure that the kube-proxy metrics service is bound to localhost", + "Checks": [], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.3 kube-proxy", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not bind the kube-proxy metrics port to non-loopback addresses.", + "RationaleStatement": "kube-proxy has two APIs which provided access to information about the service and can be bound to network ports. The metrics API service includes endpoints (`/metrics` and `/configz`) which disclose information about the configuration and operation of kube-proxy. These endpoints should not be exposed to untrusted networks as they do not support encryption or authentication to restrict access to the data they provide.", + "ImpactStatement": "3rd party services which try to access metrics or configuration information related to kube-proxy will require access to the localhost interface of the node.", + "RemediationProcedure": "Modify or remove any values which bind the metrics service to a non-localhost address", + "AuditProcedure": "review the start-up flags provided to kube proxy Run the following command on each node: ``` ps -ef | grep -i kube-proxy ``` Ensure that the `--metrics-bind-address` parameter is not set to a value other than 127.0.0.1. From the output of this command gather the location specified in the `--config` parameter. Review any file stored at that location and ensure that it does not specify a value other than 127.0.0.1 for `metricsBindAddress`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-proxy/", + "DefaultValue": "The default value is `127.0.0.1:10249`" + } + ] + }, + { + "Id": "5.1.1", + "Description": "Ensure that the cluster-admin role is only used where required", + "Checks": [ + "rbac_cluster_admin_usage" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "The RBAC role `cluster-admin` provides wide-ranging powers over the environment and should be used only where and when needed.", + "RationaleStatement": "Kubernetes provides a set of default roles where RBAC is used. Some of these roles such as `cluster-admin` provide wide-ranging privileges which should only be applied where absolutely necessary. Roles such as `cluster-admin` allow super-user access to perform any action on any resource. When used in a `ClusterRoleBinding`, it gives full control over every resource in the cluster and in all namespaces. When used in a `RoleBinding`, it gives full control over every resource in the rolebinding's namespace, including the namespace itself.", + "ImpactStatement": "Care should be taken before removing any `clusterrolebindings` from the environment to ensure they were not required for operation of the cluster. Specifically, modifications should not be made to `clusterrolebindings` with the `system:` prefix as they are required for the operation of system components.", + "RemediationProcedure": "Identify all clusterrolebindings to the cluster-admin role. Check if they are used and if they need this role or if they could use a role with fewer privileges. Where possible, first bind users to a lower privileged role and then remove the clusterrolebinding to the cluster-admin role : ``` kubectl delete clusterrolebinding [name] ```", + "AuditProcedure": "Obtain a list of the principals who have access to the `cluster-admin` role by reviewing the `clusterrolebinding` output for each role binding that has access to the `cluster-admin` role. ``` kubectl get clusterrolebindings -o=custom-columns=NAME:.metadata.name,ROLE:.roleRef.name,SUBJECT:.subjects[*].name ``` Review each principal listed and ensure that `cluster-admin` privilege is required for it.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/authorization/rbac/#user-facing-roles", + "DefaultValue": "By default a single `clusterrolebinding` called `cluster-admin` is provided with the `system:masters` group as its principal." + } + ] + }, + { + "Id": "5.1.2", + "Description": "Minimize access to secrets", + "Checks": [ + "rbac_minimize_secret_access" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "The Kubernetes API stores secrets, which may be service account tokens for the Kubernetes API or credentials used by workloads in the cluster. Access to these secrets should be restricted to the smallest possible group of users to reduce the risk of privilege escalation.", + "RationaleStatement": "Inappropriate access to secrets stored within the Kubernetes cluster can allow for an attacker to gain additional access to the Kubernetes cluster or external resources whose credentials are stored as secrets.", + "ImpactStatement": "Care should be taken not to remove access to secrets to system components which require this for their operation", + "RemediationProcedure": "Where possible, restrict access to secret objects in the cluster by removing get, list, and watch permissions.", + "AuditProcedure": "Review the users who have `get`, `list`, or `watch` access to `secrets` objects in the Kubernetes API.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default in a kubeadm cluster the following list of principals have `get` privileges on `secret` objects ``` CLUSTERROLEBINDING SUBJECT TYPE SA-NAMESPACE cluster-admin system:masters Group system:controller:clusterrole-aggregation-controller clusterrole-aggregation-controller ServiceAccount kube-system system:controller:expand-controller expand-controller ServiceAccount kube-system system:controller:generic-garbage-collector generic-garbage-collector ServiceAccount kube-system system:controller:namespace-controller namespace-controller ServiceAccount kube-system system:controller:persistent-volume-binder persistent-volume-binder ServiceAccount kube-system system:kube-controller-manager system:kube-controller-manager User ```" + } + ] + }, + { + "Id": "5.1.3", + "Description": "Minimize wildcard use in Roles and ClusterRoles", + "Checks": [ + "rbac_minimize_wildcard_use_roles" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Kubernetes Roles and ClusterRoles provide access to resources based on sets of objects and actions that can be taken on those objects. It is possible to set either of these to be the wildcard * which matches all items. Use of wildcards is not optimal from a security perspective as it may allow for inadvertent access to be granted when new resources are added to the Kubernetes API either as CRDs or in later versions of the product.", + "RationaleStatement": "The principle of least privilege recommends that users are provided only the access required for their role and nothing more. The use of wildcard rights grants is likely to provide excessive rights to the Kubernetes API.", + "ImpactStatement": "", + "RemediationProcedure": "Where possible replace any use of wildcards in ClusterRoles and Roles with specific objects or actions.", + "AuditProcedure": "Retrieve the roles defined across each namespaces in the cluster and review for wildcards ``` kubectl get roles --all-namespaces -o yaml ``` Retrieve the cluster roles defined in the cluster and review for wildcards ``` kubectl get clusterroles -o yaml ```", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.4", + "Description": "Minimize access to create pods", + "Checks": [ + "rbac_minimize_pod_creation_access" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "The ability to create pods in a namespace can provide a number of opportunities for privilege escalation, such as assigning privileged service accounts to these pods or mounting hostPaths with access to sensitive data (unless Pod Security Policies are implemented to restrict this access) As such, access to create new pods should be restricted to the smallest possible group of users.", + "RationaleStatement": "The ability to create pods in a cluster opens up possibilities for privilege escalation and should be restricted, where possible.", + "ImpactStatement": "Care should be taken not to remove access to pods to system components which require this for their operation", + "RemediationProcedure": "Where possible, remove `create` access to `pod` objects in the cluster.", + "AuditProcedure": "Review the users who have create access to pod objects in the Kubernetes API.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default in a kubeadm cluster the following list of principals have `create` privileges on `pod` objects ``` CLUSTERROLEBINDING SUBJECT TYPE SA-NAMESPACE cluster-admin system:masters Group system:controller:clusterrole-aggregation-controller clusterrole-aggregation-controller ServiceAccount kube-system system:controller:daemon-set-controller daemon-set-controller ServiceAccount kube-system system:controller:job-controller job-controller ServiceAccount kube-system system:controller:persistent-volume-binder persistent-volume-binder ServiceAccount kube-system system:controller:replicaset-controller replicaset-controller ServiceAccount kube-system system:controller:replication-controller replication-controller ServiceAccount kube-system system:controller:statefulset-controller statefulset-controller ServiceAccount kube-system ```" + } + ] + }, + { + "Id": "5.1.5", + "Description": "Ensure that default service accounts are not actively used.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "The `default` service account should not be used to ensure that rights granted to applications can be more easily audited and reviewed.", + "RationaleStatement": "Kubernetes provides a default service account which is used by cluster workloads where no specific service account is assigned to the pod. Where access to the Kubernetes API from a pod is required, a specific service account should be created for that pod, and rights granted to that service account. The default service account should be configured to ensure that it does not automatically provide a service account token, and it must not have any non-default role bindings or custom role assignments", + "ImpactStatement": "All workloads which require access to the Kubernetes API will require an explicit service account to be created.", + "RemediationProcedure": "Create explicit service accounts wherever a Kubernetes workload requires specific access to the Kubernetes API server. Modify the configuration of each default service account to include this value ``` automountServiceAccountToken: false ```", + "AuditProcedure": "For each namespace in the cluster, review the rights assigned to the default service account and ensure that it has no roles or cluster roles bound to it apart from the defaults. Additionally ensure that the `automountServiceAccountToken: false` setting is in place for each default service account.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/", + "DefaultValue": "By default the `default` service account allows for its service account token to be mounted in pods in its namespace." + } + ] + }, + { + "Id": "5.1.6", + "Description": "Ensure that Service Account Tokens are only mounted where necessary", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Service accounts tokens should not be mounted in pods except where the workload running in the pod explicitly needs to communicate with the API server", + "RationaleStatement": "Mounting service account tokens inside pods can provide an avenue for privilege escalation attacks where an attacker is able to compromise a single pod in the cluster. Avoiding mounting these tokens removes this attack avenue.", + "ImpactStatement": "Pods mounted without service account tokens will not be able to communicate with the API server, except where the resource is available to unauthenticated principals.", + "RemediationProcedure": "Modify the definition of pods and service accounts which do not need to mount service account tokens to disable it.", + "AuditProcedure": "Review pod and service account objects in the cluster and ensure that the option below is set, unless the resource explicitly requires this access. ``` automountServiceAccountToken: false ```", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/", + "DefaultValue": "By default, all pods get a service account token mounted in them." + } + ] + }, + { + "Id": "5.1.7", + "Description": "Avoid use of system:masters group", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "The special group `system:masters` should not be used to grant permissions to any user or service account, except where strictly necessary (e.g. bootstrapping access prior to RBAC being fully available)", + "RationaleStatement": "The `system:masters` group has unrestricted access to the Kubernetes API hard-coded into the API server source code. An authenticated user who is a member of this group cannot have their access reduced, even if all bindings and cluster role bindings which mention it, are removed. When combined with client certificate authentication, use of this group can allow for irrevocable cluster-admin level credentials to exist for a cluster.", + "ImpactStatement": "Once the RBAC system is operational in a cluster `system:masters` should not be specifically required, as ordinary bindings from principals to the `cluster-admin` cluster role can be made where unrestricted access is required.", + "RemediationProcedure": "Remove the `system:masters` group from all users in the cluster.", + "AuditProcedure": "Review a list of all credentials which have access to the cluster and ensure that the group `system:masters` is not used.", + "AdditionalInformation": "", + "References": "https://github.com/kubernetes/kubernetes/blob/master/pkg/registry/rbac/escalation_check.go#L38", + "DefaultValue": "By default some clusters will create a break glass client certificate which is a member of this group. Access to this client certificate should be carefully controlled and it should not be used for general cluster operations." + } + ] + }, + { + "Id": "5.1.8", + "Description": "Limit use of the Bind, Impersonate and Escalate permissions in the Kubernetes cluster", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Cluster roles and roles with the impersonate, bind or escalate permissions should not be granted unless strictly required. Each of these permissions allow a particular subject to escalate their privileges beyond those explicitly granted by cluster administrators", + "RationaleStatement": "The impersonate privilege allows a subject to impersonate other users gaining their rights to the cluster. The bind privilege allows the subject to add a binding to a cluster role or role which escalates their effective permissions in the cluster. The escalate privilege allows a subject to modify cluster roles to which they are bound, increasing their rights to that level. Each of these permissions has the potential to allow for privilege escalation to cluster-admin level.", + "ImpactStatement": "There are some cases where these permissions are required for cluster service operation, and care should be taken before removing these permissions from system service accounts.", + "RemediationProcedure": "Where possible, remove the impersonate, bind, and escalate rights from subjects.", + "AuditProcedure": "Review the users who have access to cluster roles or roles which provide the impersonate, bind, or escalate privileges.", + "AdditionalInformation": "", + "References": "https://raesene.github.io/blog/2020/12/12/Escalating_Away/:https://raesene.github.io/blog/2021/01/16/Getting-Into-A-Bind-with-Kubernetes/", + "DefaultValue": "In a default kubeadm cluster, the system:masters group and clusterrole-aggregation-controller service account have access to the escalate privilege. The system:masters group also has access to bind and impersonate." + } + ] + }, + { + "Id": "5.1.9", + "Description": "Minimize access to create persistent volumes", + "Checks": [ + "rbac_minimize_pv_creation_access" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "The ability to create persistent volumes in a cluster can provide an opportunity for privilege escalation, via the creation of `hostPath` volumes. As persistent volumes are not covered by Pod Security Admission, a user with access to create persistent volumes may be able to get access to sensitive files from the underlying host even where restrictive Pod Security Admission policies are in place.", + "RationaleStatement": "The ability to create persistent volumes in a cluster opens up possibilities for privilege escalation and should be restricted, where possible.", + "ImpactStatement": "", + "RemediationProcedure": "Where possible, remove `create` access to `PersistentVolume` objects in the cluster.", + "AuditProcedure": "Review the users who have create access to `PersistentVolume` objects in the Kubernetes API.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#persistent-volume-creation", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.10", + "Description": "Minimize access to the proxy sub-resource of nodes", + "Checks": [ + "rbac_minimize_node_proxy_subresource_access" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Users with access to the `Proxy` sub-resource of `Node` objects automatically have permissions to use the kubelet API, which may allow for privilege escalation or bypass cluster security controls such as audit logs. The kubelet provides an API which includes rights to execute commands in any container running on the node. Access to this API is covered by permissions to the main Kubernetes API via the `node` object. The proxy sub-resource specifically allows wide ranging access to the kubelet API. Direct access to the kubelet API bypasses controls like audit logging (there is no audit log of kubelet API access) and admission control.", + "RationaleStatement": "The ability to use the `proxy` sub-resource of `node` objects opens up possibilities for privilege escalation and should be restricted, where possible.", + "ImpactStatement": "", + "RemediationProcedure": "Where possible, remove access to the `proxy` sub-resource of `node` objects.", + "AuditProcedure": "Review the users who have access to the `proxy` sub-resource of `node` objects in the Kubernetes API.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#access-to-proxy-subresource-of-nodes:https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/#kubelet-authorization", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.11", + "Description": "Minimize access to the approval sub-resource of certificatesigningrequests objects", + "Checks": [ + "rbac_minimize_csr_approval_access" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Users with access to the update the `approval` sub-resource of `CertificateSigningRequests` objects can approve new client certificates for the Kubernetes API effectively allowing them to create new high-privileged user accounts. This can allow for privilege escalation to full cluster administrator, depending on users configured in the cluster", + "RationaleStatement": "The ability to update certificate signing requests should be limited.", + "ImpactStatement": "", + "RemediationProcedure": "Where possible, remove access to the `approval` sub-resource of `CertificateSigningRequests` objects.", + "AuditProcedure": "Review the users who have access to update the `approval` sub-resource of `CertificateSigningRequests` objects in the Kubernetes API.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#csrs-and-certificate-issuing", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.12", + "Description": "Minimize access to webhook configuration objects", + "Checks": [ + "rbac_minimize_webhook_config_access" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Users with rights to create/modify/delete `validatingwebhookconfigurations` or `mutatingwebhookconfigurations` can control webhooks that can read any object admitted to the cluster, and in the case of mutating webhooks, also mutate admitted objects. This could allow for privilege escalation or disruption of the operation of the cluster.", + "RationaleStatement": "The ability to manage webhook configuration should be limited", + "ImpactStatement": "", + "RemediationProcedure": "Where possible, remove access to the `validatingwebhookconfigurations` or `mutatingwebhookconfigurations` objects", + "AuditProcedure": "Review the users who have access to `validatingwebhookconfigurations` or `mutatingwebhookconfigurations` objects in the Kubernetes API.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#control-admission-webhooks", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.13", + "Description": "Minimize access to the service account token creation", + "Checks": [ + "rbac_minimize_service_account_token_creation" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Users with rights to create new service account tokens at a cluster level, can create long-lived privileged credentials in the cluster. This could allow for privilege escalation and persistent access to the cluster, even if the users account has been revoked.", + "RationaleStatement": "The ability to create service account tokens should be limited.", + "ImpactStatement": "", + "RemediationProcedure": "Where possible, remove access to the `token` sub-resource of `serviceaccount` objects.", + "AuditProcedure": "Review the users who have access to create the `token` sub-resource of `serviceaccount` objects in the Kubernetes API.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#token-request", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.2.1", + "Description": "Ensure that the cluster has at least one active policy control mechanism in place", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Every Kubernetes cluster should have at least one policy control mechanism in place to enforce the other requirements in this section. This could be the in-built Pod Security Admission controller, or a third party policy control system.", + "RationaleStatement": "Without an active policy control mechanism, it is not possible to limit the use of containers with access to underlying cluster nodes, via mechanisms like privileged containers, or the use of hostPath volume mounts.", + "ImpactStatement": "Where policy control systems are in place, there is a risk that workloads required for the operation of the cluster may be stopped from running. Care is required when implementing admission control policies to ensure that this does not occur.", + "RemediationProcedure": "Ensure that either Pod Security Admission or an external policy control system is in place for every namespace which contains user workloads.", + "AuditProcedure": "Review the workloads deployed to the cluster to understand if Pod Security Admission or external admission control systems are in place.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-admission", + "DefaultValue": "By default, Pod Security Admission is enabled but no policies are in place." + } + ] + }, + { + "Id": "5.2.2", + "Description": "Minimize the admission of privileged containers", + "Checks": [ + "core_minimize_privileged_containers" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit containers to be run with the `securityContext.privileged` flag set to `true`.", + "RationaleStatement": "Privileged containers have access to all Linux Kernel capabilities and devices. A container running with full privileges can do almost everything that the host can do. This flag exists to allow special use-cases, like manipulating the network stack and accessing devices. There should be at least one admission control policy defined which does not permit privileged containers. If you need to run privileged containers, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods defined with `spec.containers[].securityContext.privileged: true`, `spec.initContainers[].securityContext.privileged: true` and `spec.ephemeralContainers[].securityContext.privileged: true` will not be permitted.", + "RemediationProcedure": "Add policies to each namespace in the cluster which has user workloads to restrict the admission of privileged containers.", + "AuditProcedure": "Run the following command: `kubectl get pods -A -o=jsonpath='{range .items[*]}{@.metadata.name}: {@..securityContext}{end}'`It will produce an inventory of all the privileged use on the cluster, if any (please, refer to a sample below). Further grepping can be done to automate each specific violation detection. calico-kube-controllers-57b57c56f-jtmk4: {} << No Elevated Privileges calico-node-c4xv4: {} {privileged:true} {privileged:true} {privileged:true} {privileged:true} << Violates 5.2.2 dashboard-metrics-scraper-7bc864c59-2m2xw: {seccompProfile:{type:RuntimeDefault}} {allowPrivilegeEscalation:false,readOnlyRootFilesystem:true,runAsGroup:2001,runAsUser:1001}", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "DefaultValue": "By default, there are no restrictions on the creation of privileged containers." + } + ] + }, + { + "Id": "5.2.3", + "Description": "Minimize the admission of containers wishing to share the host process ID namespace", + "Checks": [ + "core_minimize_hostPID_containers" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit containers to be run with the `hostPID` flag set to true.", + "RationaleStatement": "A container running in the host's PID namespace can inspect processes running outside the container. If the container also has access to ptrace capabilities this can be used to escalate privileges outside of the container. There should be at least one admission control policy defined which does not permit containers to share the host PID namespace. If you need to run containers which require hostPID, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods defined with `spec.hostPID: true` will not be permitted unless they are run under a specific policy.", + "RemediationProcedure": "Configure the Admission Controller to restrict the admission of `hostPID` containers.", + "AuditProcedure": "Fetch hostPID from each pod with `get pods -A -o=jsonpath='{range .items[*]}{@.metadata.name}: {@.spec.hostPID}{end}'`", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "DefaultValue": "By default, there are no restrictions on the creation of `hostPID` containers." + } + ] + }, + { + "Id": "5.2.4", + "Description": "Minimize the admission of containers wishing to share the host IPC namespace", + "Checks": [ + "core_minimize_hostIPC_containers" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit containers to be run with the `hostIPC` flag set to true.", + "RationaleStatement": "A container running in the host's IPC namespace can use IPC to interact with processes outside the container. There should be at least one admission control policy defined which does not permit containers to share the host IPC namespace. If you need to run containers which require hostIPC, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods defined with `spec.hostIPC: true` will not be permitted unless they are run under a specific policy.", + "RemediationProcedure": "Add policies to each namespace in the cluster which has user workloads to restrict the admission of `hostIPC` containers.", + "AuditProcedure": "Fetch hostIPC from each pod with `get pods -A -o=jsonpath='{range .items[*]}{@.metadata.name}: {@.spec.hostIPC}{end}'`", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "DefaultValue": "By default, there are no restrictions on the creation of `hostIPC` containers." + } + ] + }, + { + "Id": "5.2.5", + "Description": "Minimize the admission of containers wishing to share the host network namespace", + "Checks": [ + "core_minimize_hostNetwork_containers" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit containers to be run with the `hostNetwork` flag set to true.", + "RationaleStatement": "A container running in the host's network namespace could access the local loopback device, and could access network traffic to and from other pods. There should be at least one admission control policy defined which does not permit containers to share the host network namespace. If you need to run containers which require access to the host's network namespaces, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods defined with `spec.hostNetwork: true` will not be permitted unless they are run under a specific policy.", + "RemediationProcedure": "Add policies to each namespace in the cluster which has user workloads to restrict the admission of `hostNetwork` containers.", + "AuditProcedure": "Fetch hostNetwork from each pod with `get pods -A -o=jsonpath='{range .items[*]}{@.metadata.name}: {@.spec.hostNetwork}{end}'`", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "DefaultValue": "By default, there are no restrictions on the creation of `hostNetwork` containers." + } + ] + }, + { + "Id": "5.2.6", + "Description": "Minimize the admission of containers with allowPrivilegeEscalation", + "Checks": [ + "core_minimize_allowPrivilegeEscalation_containers" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit containers to be run with the `allowPrivilegeEscalation` flag set to true. Allowing this right can lead to a process running a container getting more rights than it started with. It's important to note that these rights are still constrained by the overall container sandbox, and this setting does not relate to the use of privileged containers.", + "RationaleStatement": "A container running with the `allowPrivilegeEscalation` flag set to `true` may have processes that can gain more privileges than their parent. There should be at least one admission control policy defined which does not permit containers to allow privilege escalation. The option exists (and is defaulted to true) to permit setuid binaries to run. If you have need to run containers which use setuid binaries or require privilege escalation, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods defined with `securityContext: allowPrivilegeEscalation: true` will not be permitted unless they are run under a specific policy.", + "RemediationProcedure": "Add policies to each namespace in the cluster which has user workloads to restrict the admission of containers with `securityContext: allowPrivilegeEscalation: true`", + "AuditProcedure": "List the policies in use for each namespace in the cluster, ensure that each policy disallows the admission of containers which allow privilege escalation. To fetch a list of pods which `allowPrivilegeEscalation` run this command: `get pods -A -o=jsonpath='{range .items[*]}{@.metadata.name}: {@..securityContext}{end}'`", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "DefaultValue": "By default, there are no restrictions on contained process ability to escalate privileges, within the context of the container." + } + ] + }, + { + "Id": "5.2.7", + "Description": "Minimize the admission of root containers", + "Checks": [ + "core_minimize_root_containers_admission" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit containers to be run as the root user.", + "RationaleStatement": "Containers may run as any Linux user. Containers which run as the root user, whilst constrained by Container Runtime security features still have a escalated likelihood of container breakout. Ideally, all containers should run as a defined non-UID 0 user. There should be at least one admission control policy defined which does not permit root containers. If you need to run root containers, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods with containers which run as the root user will not be permitted.", + "RemediationProcedure": "Create a policy for each namespace in the cluster, ensuring that either `MustRunAsNonRoot` or `MustRunAs` with the range of UIDs not including 0, is set.", + "AuditProcedure": "List the policies in use for each namespace in the cluster, ensure that each policy restricts the use of root containers by setting `MustRunAsNonRoot` or `MustRunAs` with the range of UIDs not including 0.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "DefaultValue": "By default, there are no restrictions on the use of root containers and if a User is not specified in the image, the container will run as root." + } + ] + }, + { + "Id": "5.2.8", + "Description": "Minimize the admission of containers with the NET_RAW capability", + "Checks": [ + "core_minimize_net_raw_capability_admission" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit containers with the potentially dangerous NET_RAW capability.", + "RationaleStatement": "Containers run with a default set of capabilities as assigned by the Container Runtime. By default this can include potentially dangerous capabilities. With Docker as the container runtime the NET_RAW capability is enabled which may be misused by malicious containers. Ideally, all containers should drop this capability. There should be at least one admission control policy defined which does not permit containers with the NET_RAW capability. If you need to run containers with this capability, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods with containers which run with the NET_RAW capability will not be permitted.", + "RemediationProcedure": "Add policies to each namespace in the cluster which has user workloads to restrict the admission of containers with the `NET_RAW` capability.", + "AuditProcedure": "List the policies in use for each namespace in the cluster, ensure that at least one policy disallows the admission of containers with the `NET_RAW` capability.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/:https://www.nccgroup.trust/uk/our-research/abusing-privileged-and-unprivileged-linux-containers/", + "DefaultValue": "By default, there are no restrictions on the creation of containers with the `NET_RAW` capability." + } + ] + }, + { + "Id": "5.2.9", + "Description": "Minimize the admission of containers with capabilities assigned", + "Checks": [ + "core_minimize_containers_capabilities_assigned" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit containers with capabilities", + "RationaleStatement": "Containers run with a default set of capabilities as assigned by the Container Runtime. Capabilities are parts of the rights generally granted on a Linux system to the root user. In many cases applications running in containers do not require any capabilities to operate, so from the perspective of the principal of least privilege use of capabilities should be minimized.", + "ImpactStatement": "Pods with containers require capabilities to operate will not be permitted.", + "RemediationProcedure": "Review the use of capabilities in applications running on your cluster. Where a namespace contains applications which do not require any Linux capabilities to operate consider adding a policy which forbids the admission of containers which do not drop all capabilities.", + "AuditProcedure": "List the policies in use for each namespace in the cluster, ensure that at least one policy requires that capabilities are dropped by all containers.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/:https://www.nccgroup.trust/uk/our-research/abusing-privileged-and-unprivileged-linux-containers/", + "DefaultValue": "By default, there are no restrictions on the creation of containers with additional capabilities" + } + ] + }, + { + "Id": "5.2.10", + "Description": "Minimize the admission of Windows HostProcess Containers", + "Checks": [ + "core_minimize_admission_windows_hostprocess_containers" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit Windows containers to be run with the `hostProcess` flag set to true.", + "RationaleStatement": "A Windows container making use of the `hostProcess` flag can interact with the underlying Windows cluster node. As per the Kubernetes documentation, this provides privileged access to the Windows node. Where Windows containers are used inside a Kubernetes cluster, there should be at least one admission control policy which does not permit `hostProcess` Windows containers. If you need to run Windows containers which require `hostProcess`, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods defined with `securityContext.windowsOptions.hostProcess: true` will not be permitted unless they are run under a specific policy.", + "RemediationProcedure": "Add policies to each namespace in the cluster which has user workloads to restrict the admission of `hostProcess` containers.", + "AuditProcedure": "List the policies in use for each namespace in the cluster, ensure that each policy disallows the admission of `hostProcess` containers", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tasks/configure-pod-container/create-hostprocess-pod/:https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "DefaultValue": "By default, there are no restrictions on the creation of `hostProcess` containers." + } + ] + }, + { + "Id": "5.2.11", + "Description": "Minimize the admission of HostPath volumes", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not generally admit containers which make use of `hostPath` volumes.", + "RationaleStatement": "A container which mounts a `hostPath` volume as part of its specification will have access to the filesystem of the underlying cluster node. The use of `hostPath` volumes may allow containers access to privileged areas of the node filesystem. There should be at least one admission control policy defined which does not permit containers to mount `hostPath` volumes. If you need to run containers which require `hostPath` volumes, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods defined which make use of `hostPath` volumes will not be permitted unless they are run under a specific policy.", + "RemediationProcedure": "Add policies to each namespace in the cluster which has user workloads to restrict the admission of containers which use `hostPath` volumes.", + "AuditProcedure": "List the policies in use for each namespace in the cluster, ensure that each policy disallows the admission of containers with `hostPath` volumes.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "DefaultValue": "By default, there are no restrictions on the creation of `hostPath` volumes." + } + ] + }, + { + "Id": "5.2.12", + "Description": "Minimize the admission of containers which use HostPorts", + "Checks": [ + "core_minimize_admission_hostport_containers" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit containers which require the use of HostPorts.", + "RationaleStatement": "Host ports connect containers directly to the host's network. This can bypass controls such as network policy. There should be at least one admission control policy defined which does not permit containers which require the use of HostPorts. If you need to run containers which require HostPorts, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods defined with `hostPort` settings in either the container, initContainer or ephemeralContainer sections will not be permitted unless they are run under a specific policy.", + "RemediationProcedure": "Add policies to each namespace in the cluster which has user workloads to restrict the admission of containers which use `hostPort` sections.", + "AuditProcedure": "List the policies in use for each namespace in the cluster, ensure that each policy disallows the admission of containers which have `hostPort` sections.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "DefaultValue": "By default, there are no restrictions on the use of HostPorts." + } + ] + }, + { + "Id": "5.3.1", + "Description": "Ensure that the CNI in use supports Network Policies", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.3 Network Policies and CNI", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "There are a variety of CNI plugins available for Kubernetes. If the CNI in use does not support Network Policies it may not be possible to effectively restrict traffic in the cluster.", + "RationaleStatement": "Kubernetes network policies are enforced by the CNI plugin in use. As such it is important to ensure that the CNI plugin supports both Ingress and Egress network policies.", + "ImpactStatement": "None", + "RemediationProcedure": "If the CNI plugin in use does not support network policies, consideration should be given to making use of a different plugin, or finding an alternate mechanism for restricting traffic in the Kubernetes cluster.", + "AuditProcedure": "Review the documentation of CNI plugin in use by the cluster, and confirm that it supports Ingress and Egress network policies.", + "AdditionalInformation": "One example here is Flannel (https://github.com/coreos/flannel) which does not support Network policy unless Calico is also in use.", + "References": "https://kubernetes.io/docs/concepts/extend-kubernetes/compute-storage-net/network-plugins/", + "DefaultValue": "This will depend on the CNI plugin in use." + } + ] + }, + { + "Id": "5.3.2", + "Description": "Ensure that all Namespaces have Network Policies defined", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.3 Network Policies and CNI", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Use network policies to isolate traffic in your cluster network.", + "RationaleStatement": "Running different applications on the same Kubernetes cluster creates a risk of one compromised application attacking a neighboring application. Network segmentation is important to ensure that containers can communicate only with those they are supposed to. A network policy is a specification of how selections of pods are allowed to communicate with each other and other network endpoints. Network Policies are namespace scoped. When a network policy is introduced to a given namespace, all traffic not allowed by the policy is denied. However, if there are no network policies in a namespace all traffic will be allowed into and out of the pods in that namespace.", + "ImpactStatement": "Once network policies are in use within a given namespace, traffic not explicitly allowed by a network policy will be denied. As such it is important to ensure that, when introducing network policies, legitimate traffic is not blocked.", + "RemediationProcedure": "Follow the documentation and create `NetworkPolicy` objects as you need them.", + "AuditProcedure": "Run the below command and review the `NetworkPolicy` objects created in the cluster. ``` kubectl get networkpolicy --all-namespaces ``` Ensure that each namespace defined in the cluster has at least one Network Policy.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/services-networking/networkpolicies/:https://octetz.com/posts/k8s-network-policy-apis:https://kubernetes.io/docs/tasks/configure-pod-container/declare-network-policy/", + "DefaultValue": "By default, network policies are not created." + } + ] + }, + { + "Id": "5.4.1", + "Description": "Prefer using secrets as files over secrets as environment variables", + "Checks": [ + "core_no_secrets_envs" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.4 Secrets Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Kubernetes supports mounting secrets as data volumes or as environment variables. Minimize the use of environment variable secrets.", + "RationaleStatement": "It is reasonably common for application code to log out its environment (particularly in the event of an error). This will include any secret values passed in as environment variables, so secrets can easily be exposed to any user or entity who has access to the logs.", + "ImpactStatement": "Application code which expects to read secrets in the form of environment variables would need modification", + "RemediationProcedure": "If possible, rewrite application code to read secrets from mounted secret files, rather than from environment variables.", + "AuditProcedure": "Run the following command to find references to objects which use environment variables defined from secrets. ``` kubectl get all -o jsonpath='{range .items[?(@..secretKeyRef)]}{.kind}{@.metadata.name}{end}' -A ```", + "AdditionalInformation": "Mounting secrets as volumes has the additional benefit that secret values can be updated without restarting the pod", + "References": "https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets", + "DefaultValue": "By default, secrets are not defined" + } + ] + }, + { + "Id": "5.4.2", + "Description": "Consider external secret storage", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.4 Secrets Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Consider the use of an external secrets storage and management system, instead of using Kubernetes Secrets directly, if you have more complex secret management needs. Ensure the solution requires authentication to access secrets, has auditing of access to and use of secrets, and encrypts secrets. Some solutions also make it easier to rotate secrets.", + "RationaleStatement": "Kubernetes supports secrets as first-class objects, but care needs to be taken to ensure that access to secrets is carefully limited. Using an external secrets provider can ease the management of access to secrets, especially where secrests are used across both Kubernetes and non-Kubernetes environments.", + "ImpactStatement": "None", + "RemediationProcedure": "Refer to the secrets management options offered by your cloud provider or a third-party secrets management solution.", + "AuditProcedure": "Review your secrets management implementation.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, no external secret management is configured." + } + ] + }, + { + "Id": "5.5.1", + "Description": "Configure Image Provenance using ImagePolicyWebhook admission controller", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.5 Extensible Admission Control", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Configure Image Provenance for your deployment.", + "RationaleStatement": "Kubernetes supports plugging in provenance rules to accept or reject the images in your deployments. You could configure such rules to ensure that only approved images are deployed in the cluster.", + "ImpactStatement": "You need to regularly maintain your provenance configuration based on container image updates.", + "RemediationProcedure": "Follow the Kubernetes documentation and setup image provenance.", + "AuditProcedure": "Review the pod definitions in your cluster and verify that image provenance is configured as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/admission-controllers/#imagepolicywebhook:https://github.com/kubernetes/community/blob/master/contributors/design-proposals/image-provenance.md:https://hub.docker.com/r/dnurmi/anchore-toolbox/:https://github.com/kubernetes/kubernetes/issues/22888", + "DefaultValue": "By default, image provenance is not set." + } + ] + }, + { + "Id": "5.6.1", + "Description": "Create administrative boundaries between resources using namespaces", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.6 General Policies", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Use namespaces to isolate your Kubernetes objects.", + "RationaleStatement": "Limiting the scope of user permissions can reduce the impact of mistakes or malicious activities. A Kubernetes namespace allows you to partition created resources into logically named groups. Resources created in one namespace can be hidden from other namespaces. By default, each resource created by a user in Kubernetes cluster runs in a default namespace, called `default`. You can create additional namespaces and attach resources and users to them. You can use Kubernetes Authorization plugins to create policies that segregate access to namespace resources between different users.", + "ImpactStatement": "You need to switch between namespaces for administration.", + "RemediationProcedure": "Follow the documentation and create namespaces for objects in your deployment as you need them.", + "AuditProcedure": "Run the below command and review the namespaces created in the cluster. ``` kubectl get namespaces ``` Ensure that these namespaces are the ones you need and are adequately administered as per your requirements.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/#viewing-namespaces:http://blog.kubernetes.io/2016/08/security-best-practices-kubernetes-deployment.html:https://github.com/kubernetes/enhancements/tree/master/keps/sig-node/589-efficient-node-heartbeats", + "DefaultValue": "By default, Kubernetes starts with 4 initial namespaces: 1. `default` - The default namespace for objects with no other namespace 1. `kube-system` - The namespace for objects created by the Kubernetes system 1. `kube-node-lease` - Namespace used for node heartbeats 1. `kube-public` - Namespace used for public information in a cluster" + } + ] + }, + { + "Id": "5.6.2", + "Description": "Ensure that the seccomp profile is set to docker/default in your pod definitions", + "Checks": [ + "core_seccomp_profile_docker_default" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.6 General Policies", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Enable `docker/default` seccomp profile in your pod definitions.", + "RationaleStatement": "Seccomp (secure computing mode) is used to restrict the set of system calls applications can make, allowing cluster administrators greater control over the security of workloads running in the cluster. Kubernetes disables seccomp profiles by default for historical reasons. You should enable it to ensure that the workloads have restricted actions available within the container.", + "ImpactStatement": "If the `docker/default` seccomp profile is too restrictive for you, you would have to create/manage your own seccomp profiles.", + "RemediationProcedure": "Use security context to enable the `docker/default` seccomp profile in your pod definitions. An example is as below: ``` securityContext: seccompProfile: type: RuntimeDefault ```", + "AuditProcedure": "Review the pod definitions in your cluster. It should create a line as below: ``` securityContext: seccompProfile: type: RuntimeDefault ```", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tutorials/clusters/seccomp/:https://docs.docker.com/engine/security/seccomp/", + "DefaultValue": "By default, seccomp profile is set to `unconfined` which means that no seccomp profiles are enabled." + } + ] + }, + { + "Id": "5.6.3", + "Description": "Apply Security Context to Your Pods and Containers", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.6 General Policies", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Apply Security Context to Your Pods and Containers", + "RationaleStatement": "A security context defines the operating system security settings (uid, gid, capabilities, SELinux role, etc..) applied to a container. When designing your containers and pods, make sure that you configure the security context for your pods, containers, and volumes. A security context is a property defined in the deployment yaml. It controls the security parameters that will be assigned to the pod/container/volume. There are two levels of security context: pod level security context, and container level security context.", + "ImpactStatement": "If you incorrectly apply security contexts, you may have trouble running the pods.", + "RemediationProcedure": "Follow the Kubernetes documentation and apply security contexts to your pods. For a suggested list of security contexts, you may refer to the CIS Security Benchmark for Docker Containers.", + "AuditProcedure": "Review the pod definitions in your cluster and verify that you have security contexts defined as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/policy/security-context/:https://learn.cisecurity.org/benchmarks", + "DefaultValue": "By default, no security contexts are automatically applied to pods." + } + ] + }, + { + "Id": "5.6.4", + "Description": "The default namespace should not be used", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.6 General Policies", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Kubernetes provides a default namespace, where objects are placed if no namespace is specified for them. Placing objects in this namespace makes application of RBAC and other controls more difficult.", + "RationaleStatement": "Resources in a Kubernetes cluster should be segregated by namespace, to allow for security controls to be applied at that level and to make it easier to manage resources.", + "ImpactStatement": "None", + "RemediationProcedure": "Ensure that namespaces are created to allow for appropriate segregation of Kubernetes resources and that all new resources are created in a specific namespace.", + "AuditProcedure": "Run this command to list objects in default namespace ``` kubectl get $(kubectl api-resources --verbs=list --namespaced=true -o name | paste -sd, -) --ignore-not-found -n default ``` The only entries there should be system managed resources such as the `kubernetes` service", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "Unless a namespace is specific on object creation, the `default` namespace will be used" + } + ] + } + ] +} diff --git a/prowler/compliance/kubernetes/pci_4.0_kubernetes.json b/prowler/compliance/kubernetes/pci_4.0_kubernetes.json index 0b301a0645..38d9557d5f 100644 --- a/prowler/compliance/kubernetes/pci_4.0_kubernetes.json +++ b/prowler/compliance/kubernetes/pci_4.0_kubernetes.json @@ -8268,6 +8268,14 @@ "Checks": [ "apiserver_audit_log_maxage_set" ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "10.5.1: Audit log history is retained and available for analysis.", @@ -10054,6 +10062,14 @@ "Checks": [ "apiserver_audit_log_maxage_set" ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "3.3.1.3: Sensitive authentication data (SAD) is not stored after authorization.", @@ -10250,6 +10266,14 @@ "Checks": [ "apiserver_audit_log_maxage_set" ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "3.3.3: Sensitive authentication data (SAD) is not stored after authorization.", @@ -13004,6 +13028,14 @@ "Checks": [ "apiserver_audit_log_maxage_set" ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "5.3.4: Anti-malware mechanisms and processes are active, maintained, and monitored.", diff --git a/prowler/compliance/kubernetes/prowler_threatscore_kubernetes.json b/prowler/compliance/kubernetes/prowler_threatscore_kubernetes.json index 11ffe42485..58ef7c6ddb 100644 --- a/prowler/compliance/kubernetes/prowler_threatscore_kubernetes.json +++ b/prowler/compliance/kubernetes/prowler_threatscore_kubernetes.json @@ -1083,6 +1083,23 @@ "LevelOfRisk": 4, "Weight": 100 } + ], + "ConfigRequirements": [ + { + "Check": "kubelet_strong_ciphers_only", + "ConfigKey": "kubelet_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_128_GCM_SHA256" + ] + } ] }, { @@ -1199,6 +1216,14 @@ "Checks": [ "apiserver_audit_log_maxage_set" ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 30 + } + ], "Attributes": [ { "Title": "API Server audit log retention configured", @@ -1227,6 +1252,14 @@ "LevelOfRisk": 3, "Weight": 10 } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxbackup_set", + "ConfigKey": "audit_log_maxbackup", + "Operator": "gte", + "Value": 10 + } ] }, { @@ -1245,6 +1278,14 @@ "LevelOfRisk": 2, "Weight": 8 } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxsize_set", + "ConfigKey": "audit_log_maxsize", + "Operator": "gte", + "Value": 100 + } ] }, { diff --git a/prowler/compliance/m365/cis_4.0_m365.json b/prowler/compliance/m365/cis_4.0_m365.json index 23582fbaac..35d64d358e 100644 --- a/prowler/compliance/m365/cis_4.0_m365.json +++ b/prowler/compliance/m365/cis_4.0_m365.json @@ -565,6 +565,68 @@ "References": "https://learn.microsoft.com/en-us/powershell/module/exchange/get-malwarefilterpolicy?view=exchange-ps:https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/anti-malware-policies-configure?view=o365-worldwide:https://learn.microsoft.com/en-us/office/compatibility/office-file-format-reference", "DefaultValue": "The following extensions are blocked by default:ace, ani, apk, app, appx, arj, bat, cab, cmd, com, deb, dex, dll, docm, elf, exe, hta, img, iso, jar, jnlp, kext, lha, lib, library, lnk, lzh, macho, msc, msi, msix, msp, mst, pif, ppa, ppam, reg, rev, scf, scr, sct, sys, uif, vb, vbe, vbs, vxd, wsc, wsf, wsh, xll, xz, z" } + ], + "ConfigRequirements": [ + { + "Check": "defender_malware_policy_comprehensive_attachments_filter_applied", + "ConfigKey": "recommended_blocked_file_types", + "Operator": "superset", + "Value": [ + "ace", + "ani", + "apk", + "app", + "appx", + "arj", + "bat", + "cab", + "cmd", + "com", + "deb", + "dex", + "dll", + "docm", + "elf", + "exe", + "hta", + "img", + "iso", + "jar", + "jnlp", + "kext", + "lha", + "lib", + "library", + "lnk", + "lzh", + "macho", + "msc", + "msi", + "msix", + "msp", + "mst", + "pif", + "ppa", + "ppam", + "reg", + "rev", + "scf", + "scr", + "sct", + "sys", + "uif", + "vb", + "vbe", + "vbs", + "vxd", + "wsc", + "wsf", + "wsh", + "xll", + "xz", + "z" + ] + } ] }, { @@ -1209,6 +1271,14 @@ "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-session-lifetime", "DefaultValue": "The default configuration for user sign-in frequency is a rolling window of 90 days." } + ], + "ConfigRequirements": [ + { + "Check": "entra_admin_users_sign_in_frequency_enabled", + "ConfigKey": "sign_in_frequency", + "Operator": "lte", + "Value": 4 + } ] }, { diff --git a/prowler/compliance/m365/cis_6.0_m365.json b/prowler/compliance/m365/cis_6.0_m365.json index d0dcfb2e6d..5815c67ac9 100644 --- a/prowler/compliance/m365/cis_6.0_m365.json +++ b/prowler/compliance/m365/cis_6.0_m365.json @@ -582,6 +582,68 @@ "References": "https://learn.microsoft.com/en-us/powershell/module/exchange/get-malwarefilterpolicy?view=exchange-ps:https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/anti-malware-policies-configure?view=o365-worldwide:https://learn.microsoft.com/en-us/office/compatibility/office-file-format-reference", "DefaultValue": "53 extensions are blocked by default." } + ], + "ConfigRequirements": [ + { + "Check": "defender_malware_policy_comprehensive_attachments_filter_applied", + "ConfigKey": "recommended_blocked_file_types", + "Operator": "superset", + "Value": [ + "ace", + "ani", + "apk", + "app", + "appx", + "arj", + "bat", + "cab", + "cmd", + "com", + "deb", + "dex", + "dll", + "docm", + "elf", + "exe", + "hta", + "img", + "iso", + "jar", + "jnlp", + "kext", + "lha", + "lib", + "library", + "lnk", + "lzh", + "macho", + "msc", + "msi", + "msix", + "msp", + "mst", + "pif", + "ppa", + "ppam", + "reg", + "rev", + "scf", + "scr", + "sct", + "sys", + "uif", + "vb", + "vbe", + "vbs", + "vxd", + "wsc", + "wsf", + "wsh", + "xll", + "xz", + "z" + ] + } ] }, { @@ -1949,6 +2011,14 @@ "References": "https://learn.microsoft.com/en-us/purview/audit-mailboxes?view=o365-worldwide", "DefaultValue": "AuditEnabled: True for all mailboxes except Resource Mailboxes, Public Folder Mailboxes, and DiscoverySearch Mailbox" } + ], + "ConfigRequirements": [ + { + "Check": "exchange_user_mailbox_auditing_enabled", + "ConfigKey": "audit_log_age", + "Operator": "gte", + "Value": 90 + } ] }, { @@ -2110,6 +2180,14 @@ "References": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/mailtips/mailtips", "DefaultValue": "MailTipsAllTipsEnabled: True, MailTipsExternalRecipientsTipsEnabled: False, MailTipsGroupMetricsEnabled: True, MailTipsLargeAudienceThreshold: 25" } + ], + "ConfigRequirements": [ + { + "Check": "exchange_organization_mailtips_enabled", + "ConfigKey": "recommended_mailtips_large_audience_threshold", + "Operator": "lte", + "Value": 25 + } ] }, { diff --git a/prowler/compliance/m365/cis_7.0_m365.json b/prowler/compliance/m365/cis_7.0_m365.json new file mode 100644 index 0000000000..a913f339be --- /dev/null +++ b/prowler/compliance/m365/cis_7.0_m365.json @@ -0,0 +1,3614 @@ +{ + "Framework": "CIS", + "Name": "CIS Microsoft 365 Foundations Benchmark v7.0.0", + "Version": "7.0", + "Provider": "M365", + "Description": "The CIS Microsoft 365 Foundations Benchmark provides prescriptive guidance for establishing a secure configuration posture for Microsoft 365 Cloud offerings running on any OS. This guide includes recommendations for Exchange Online, SharePoint Online, OneDrive for Business, Teams, Power BI (Fabric) and Microsoft Entra ID.", + "Requirements": [ + { + "Id": "1.1.1", + "Description": "Administrative accounts are special privileged accounts that could have varying levels of access to data, users, and settings. Regular user accounts should never be utilized for administrative tasks and care should be taken, in the case of a hybrid environment, to keep administrative accounts separate from on-prem accounts. Administrative accounts should not have applications assigned so that they have no access to potentially vulnerable services (EX. email, Teams, SharePoint, etc.) and only access to perform tasks as needed for administrative purposes. Ensure administrative accounts are not On-premises sync enabled.", + "Checks": [ + "entra_admin_users_cloud_only" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.1 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Administrative accounts are special privileged accounts that could have varying levels of access to data, users, and settings. Regular user accounts should never be utilized for administrative tasks and care should be taken, in the case of a hybrid environment, to keep administrative accounts separate from on-prem accounts. Administrative accounts should not have applications assigned so that they have no access to potentially vulnerable services (EX. email, Teams, SharePoint, etc.) and only access to perform tasks as needed for administrative purposes. Ensure administrative accounts are not On-premises sync enabled.", + "RationaleStatement": "In a hybrid environment, having separate accounts will help ensure that in the event of a breach in the cloud, that the breach does not affect the on-prem environment and vice versa.", + "ImpactStatement": "Administrative users will need to utilize login/logout functionality to switch accounts when performing administrative tasks, which means they will not benefit from SSO. This will require a migration process from the 'daily driver' account to a dedicated admin account. Once the new admin account is created, permission sets should be migrated from the 'daily driver' account to the new admin account. This includes both M365 and Azure RBAC roles. Failure to migrate Azure RBAC roles could prevent an admin from seeing their subscriptions/resources while using their admin account.", + "RemediationProcedure": "Remediation will require first identifying the privileged accounts that are synced from on- premises and then creating a new cloud-only account for that user. Once a replacement account is established, the hybrid account should have its role reduced to that of a non- privileged user or removed depending on the need.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Identity > Users and select All users. 3. To the right of the search box click the Add filter button. 4. Add the On-premises sync enabled filter with the value set to Yes and click Apply. 5. Verify that no user accounts in administrative roles are present in the filtered list. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"RoleManagement.Read.Directory\",\"User.Read.All\" 2. Run the following PowerShell script: $DirectoryRoles = Get-MgDirectoryRole # Get privileged role IDs $PrivilegedRoles = $DirectoryRoles | Where-Object { $_.DisplayName -like \"*Administrator*\" -or $_.DisplayName -eq \"Global Reader\" } # Get the members of these various roles $RoleMembers = $PrivilegedRoles | ForEach-Object { Get-MgDirectoryRoleMember -DirectoryRoleId $_.Id } | Select-Object Id -Unique # Retrieve details about the members in these roles $PrivilegedUsers = $RoleMembers | ForEach-Object { Get-MgUser -UserId $_.Id -Property UserPrincipalName, DisplayName, Id, OnPremisesSyncEnabled } $PrivilegedUsers | Where-Object { $_.OnPremisesSyncEnabled -eq $true } | ft DisplayName,UserPrincipalName,OnPremisesSyncEnabled 3. The script will output any hybrid users that are also members of privileged roles. If nothing returns, then no users with that criteria exist.", + "AdditionalInformation": "", + "DefaultValue": "N/A", + "References": "https://learn.microsoft.com/en-us/microsoft-365/admin/add-users/add-users?view=o365-worldwide:https://learn.microsoft.com/en-us/microsoft-365/enterprise/protect-your-global-administrator-accounts?view=o365-worldwide:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/best-practices#9-use-cloud-native-accounts-for-microsoft-entra-roles:https://learn.microsoft.com/en-us/entra/fundamentals/whatis:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference" + } + ] + }, + { + "Id": "1.1.2", + "Description": "Emergency access or \"break glass\" accounts are limited for emergency scenarios where normal administrative accounts are unavailable. They are not assigned to a specific user and will have a combination of physical and technical controls to prevent them from being accessed outside a true emergency. These emergencies could be due to several things, including: - Technical failures of a cellular provider or Microsoft related service such as MFA. - The last remaining Global Administrator account is inaccessible. Ensure two Emergency Access accounts have been defined. Note: Microsoft provides several recommendations for these accounts and how to configure them. For more information on this, please refer to the references section. The CIS Benchmark outlines the more critical things to consider.", + "Checks": [ + "entra_break_glass_account_fido2_security_key_registered", + "entra_emergency_access_exclusion" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.1 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Manual", + "Description": "Emergency access or \"break glass\" accounts are limited for emergency scenarios where normal administrative accounts are unavailable. They are not assigned to a specific user and will have a combination of physical and technical controls to prevent them from being accessed outside a true emergency. These emergencies could be due to several things, including: - Technical failures of a cellular provider or Microsoft related service such as MFA. - The last remaining Global Administrator account is inaccessible. Ensure two Emergency Access accounts have been defined. Note: Microsoft provides several recommendations for these accounts and how to configure them. For more information on this, please refer to the references section. The CIS Benchmark outlines the more critical things to consider.", + "RationaleStatement": "In various situations, an organization may require the use of a break glass account to gain emergency access. In the event of losing access to administrative functions, an organization may experience a significant loss in its ability to provide support, lose insight into its security posture, and potentially suffer financial losses.", + "ImpactStatement": "Failure to properly implement emergency access accounts can weaken the security posture. Microsoft recommends excluding at least one of the two emergency access accounts from all conditional access rules, necessitating passwords with sufficient entropy and length to protect against random guesses. For a secure passwordless solution, FIDO2 security keys may be used instead of passwords.", + "RemediationProcedure": "To remediate using the UI: Step 1 - Create two emergency access accounts: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com 2. Expand Users > Active Users 3. Click Add user and create a new user with this criteria: o Name the account in a way that does NOT identify it with a particular person. o Assign the account to the default .onmicrosoft.com domain and not the organization's. o The password must be at least 16 characters and generated randomly. o Do not assign a license. o Assign the user the Global Administrator role. 4. Repeat the above steps for the second account. Step 2 - Exclude at least one account from conditional access policies: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Protection > Conditional Access. 3. Inspect the conditional access policies. 4. For each rule add an exclusion for at least one of the emergency access accounts. 5. Users > Exclude > Users and groups and select one emergency access account. Step 3 - Ensure the necessary procedures and policies are in place: - In order for accounts to be effectively used in a break glass situation the proper policies and procedures must be authorized and distributed by senior management. - FIDO2 Security Keys should be locked in a secure separate fireproof location. - Passwords should be at least 16 characters, randomly generated and MAY be separated in multiple pieces to be joined in case of an emergency. Warning: As of 10/15/2024 MFA is required for all users including Break Glass Accounts. It is recommended to update these accounts to use passkey (FIDO2) or configure certificate-based authentication for MFA. Both methods satisfy the MFA requirement. Additional suggestions for emergency account management: - Create access reviews for these users. - Exclude users from conditional access rules. - Add the account to a restricted management administrative unit. Warning: If CA (conditional access) exclusion is managed by a group, this group should be added to PIM for groups (licensing required) or be created as a role-assignable group. If it is a regular security group, then users with the Group Administrators role are able to bypass CA entirely.", + "AuditProcedure": "To audit using the UI: Step 1 - Ensure a policy and procedure is in place at the organization: - In order for accounts to be effectively used in a break-glass situation the proper policies and procedures must be authorized and distributed by senior management. - FIDO2 Security Keys should be locked in a secure separate fireproof location. - Passwords should be at least 16 characters, randomly generated and MAY be separated in multiple pieces to be joined in case of an emergency. Step 2 - Ensure two emergency access accounts are defined: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com 2. Expand Users > Active Users 3. Inspect the designated emergency access accounts and ensure the following: o The accounts are named correctly, and do NOT identify with a particular person. o The accounts use the default .onmicrosoft.com domain and not the organization's. o The accounts are cloud-only. o The accounts are unlicensed. o The accounts are not disabled or Sign-in blocked. o The accounts are assigned the Global Administrator directory role. Step 3 - Ensure at least one account is excluded from all conditional access rules: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Protection > Conditional Access. 3. Inspect the conditional access rules. 4. Ensure one of the emergency access accounts is excluded from all rules. Warning: As of 10/15/2024 MFA is required for all users including Break Glass Accounts. It is recommended to update these accounts to use passkey (FIDO2) or configure certificate-based authentication for MFA. Both methods satisfy the MFA requirement.", + "AdditionalInformation": "Microsoft has additional instructions regarding using Azure Monitor to capture events in the Log Analytics workspace, and then generate alerts for Emergency Access accounts. This requires an Azure subscription but should be strongly considered as a method of monitoring activity on these accounts: https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security- emergency-access#monitor-sign-in-and-audit-logs", + "DefaultValue": "Not defined.", + "References": "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-planning#stage-1-critical-items-to-do-right-now:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-emergency-access:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/admin-units-restricted-management:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-mandatory-multifactor-authentication#accounts" + } + ] + }, + { + "Id": "1.1.3", + "Description": "Between two and four global administrators should be designated in the tenant. Ideally, these accounts will not have licenses assigned to them which supports additional controls found in this benchmark.", + "Checks": [ + "admincenter_users_between_two_and_four_global_admins" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.1 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Between two and four global administrators should be designated in the tenant. Ideally, these accounts will not have licenses assigned to them which supports additional controls found in this benchmark.", + "RationaleStatement": "The Global Administrator role grants unrestricted access across all services in Microsoft Entra ID and should never be used for routine daily activities. Limiting the number of Global Administrators reduces the attack surface of the tenant and aligns with the principle of least privilege. Fewer than two Global Administrators creates a single point of failure and removes the peer oversight needed to detect unauthorized actions. More than four increases the likelihood of account compromise by an external attacker. Maintaining between two and four Global Administrators balances operational redundancy against privileged access risk. For any accounts assigned the Global Administrator role, at least one strong authentication method such as a FIDO2 key or certificate is strongly advised.", + "ImpactStatement": "The potential impact associated with ensuring compliance with this requirement is dependent upon the current number of global administrators configured in the tenant. If there is only one global administrator in a tenant, an additional global administrator will need to be identified and configured. If there are more than four global administrators, a review of role requirements for current global administrators will be required to identify which of the users require global administrator access.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft 365 admin center https://admin.microsoft.com 2. Select Users > Active Users. 3. In the Search field enter the name of the user to be made a Global Administrator. 4. To create a new Global Admin: 1. Select the user's name. 2. A window will appear to the right. 3. Select Manage roles. 4. Select Admin center access. 5. Check Global Administrator. 6. Click Save changes. 5. To remove a Global Admin: 1. In the Search field, enter the name of the user to be removed. 2. Select the user's name. 3. A window will appear to the right. 4. Under Roles, select Manage roles. 5. Uncheck Global Administrator. 6. Click Save changes.", + "AuditProcedure": "Note: If an organization's tenant is using a third-party identity provider, the audit and remediation procedures presented here may not be relevant. The principle of the recommendation is still relevant, and compensating controls that are relevant to the third-party identity provider should be implemented. To audit using the UI: 1. Navigate to the Microsoft 365 admin center https://admin.microsoft.com 2. Select Roles > Role assignments. 3. Select the Global Administrator role from the list and click on Assigned. 4. Review the list of Global Administrators. o If there are groups present, then inspect each group and its members. o Take note of the total number of Global Administrators in and outside of groups. 5. Verify the number of Global Administrators is between two and four. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes Directory.Read.All 2. Run the following PowerShell script: # Determine Id of GA role using the immutable RoleTemplateId value. $GlobalAdminRole = Get-MgDirectoryRole -Filter \"RoleTemplateId eq '62e90394- 69f5-4237-9190-012177145e10'\" $RoleMembers = Get-MgDirectoryRoleMember -DirectoryRoleId $GlobalAdminRole.Id $GlobalAdmins = [System.Collections.Generic.List[Object]]::new() foreach ($object in $RoleMembers) { $Type = $object.AdditionalProperties.'@odata.type' # Check for and process role assigned groups if ($Type -eq '#microsoft.graph.group') { $GroupId = $object.Id $GroupMembers = (Get-MgGroupMember -GroupId $GroupId).AdditionalProperties foreach ($member in $GroupMembers) { if ($member.'@odata.type' -eq '#microsoft.graph.user') { $GlobalAdmins.Add([PSCustomObject][Ordered]@{ DisplayName = $member.displayName UserPrincipalName = $member.userPrincipalName }) } } } elseif ($Type -eq '#microsoft.graph.user') { $DisplayName = $object.AdditionalProperties.displayName $UPN = $object.AdditionalProperties.userPrincipalName $GlobalAdmins.Add([PSCustomObject][Ordered]@{ DisplayName = $DisplayName UserPrincipalName = $UPN }) } } $GlobalAdmins = $GlobalAdmins | select DisplayName,UserPrincipalName -Unique Write-Host \"*** There are\" $GlobalAdmins.Count \"Global Administrators in the organization.\" 3. Review the output and ensure there are between 2 and 4 Global Administrators. Note: When tallying the number of Global Administrators, the above does not account for Partner relationships. Those are located under Settings > Partner Relationships and should be reviewed on a recurring basis.", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.identity.directorymanagement/get-mgdirectoryrole?view=graph-powershell-1.0:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#all-roles:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/best-practices#5-limit-the-number-of-global-administrators-to-less-than-5:https://learn.microsoft.com/en-us/microsoft-365/admin/add-users/about-admin-roles?view=o365-worldwide#security-guidelines-for-assigning-roles" + } + ] + }, + { + "Id": "1.1.4", + "Description": "Administrative accounts are special privileged accounts that could have varying levels of access to data, users, and settings. A license can enable an account to gain access to a variety of different applications, depending on the license assigned. The recommended state is to not license a privileged account or use licenses without associated applications such as Microsoft Entra ID P1 or Microsoft Entra ID P2.", + "Checks": [ + "admincenter_users_admins_reduced_license_footprint" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.1 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Administrative accounts are special privileged accounts that could have varying levels of access to data, users, and settings. A license can enable an account to gain access to a variety of different applications, depending on the license assigned. The recommended state is to not license a privileged account or use licenses without associated applications such as Microsoft Entra ID P1 or Microsoft Entra ID P2.", + "RationaleStatement": "Ensuring administrative accounts do not use licenses with applications assigned to them will reduce the attack surface of high privileged identities in the organization's environment. Granting access to a mailbox or other collaborative tools increases the likelihood that privileged users might interact with these applications, raising the risk of exposure to social engineering attacks or malicious content. These activities should be restricted to an unprivileged 'daily driver' account. Note: In order to participate in Microsoft 365 security services such as Identity Protection, PIM and Conditional Access an administrative account will need a license attached to it. Ensure that the license used does not include any applications with potentially vulnerable services by using either Microsoft Entra ID P1 or Microsoft Entra ID P2 for the cloud-only account with administrator roles.", + "ImpactStatement": "Administrative users will be required to switch accounts and use manual login/logout procedures when performing privileged tasks. This change also means they will not benefit from Single Sign-On (SSO), potentially impacting workflow efficiency and user experience. Note: Alerts will be sent to TenantAdmins, including Global Administrators, by default. To ensure proper receipt, configure alerts to be sent to security or operations staff with valid email addresses or a security operations center. Otherwise, after adoption of this recommendation, alerts sent to TenantAdmins may go unreceived due to the lack of an application-based license assigned to the Global Administrator accounts.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Users > Active users. 3. Click Add a user. 4. Fill out the appropriate fields for Name, user, etc. 5. When prompted to assign licenses select as needed Microsoft Entra ID P1 or Microsoft Entra ID P2, then click Next. 6. Under the Option settings screen you may choose from several types of privileged roles. Choose Admin center access followed by the appropriate role then click Next. 7. Select Finish adding. Note: Utilizing PIM to best practices will satisfy this control. CIS and Microsoft recommend an organization keep zero permanently active assignments for roles other than emergency access accounts.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Users > Active users. 3. Sort by the Licenses column. 4. For each user account in an administrative role verify the account is assigned a license that is not associated with applications i.e. (Microsoft Entra ID P1, Microsoft Entra ID P2). o If an organization uses PIM to elevate a daily driver account to privileged levels, this control and licensing requirement can be considered satisfied. Note: The final step assumes PIM is properly configured to best practices. Accounts eligible for the Global Administrator role should require approval to activate. Using the PIM blade to permanently assign accounts to privileged roles would not satisfy this audit procedure. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"RoleManagement.Read.Directory\",\"User.Read.All\" 2. Run the following PowerShell script: $DirectoryRoles = Get-MgDirectoryRole # Get privileged role IDs $PrivilegedRoles = $DirectoryRoles | Where-Object { $_.DisplayName -like \"*Administrator*\" -or $_.DisplayName -eq \"Global Reader\" } # Get the members of these various roles $RoleMembers = $PrivilegedRoles | ForEach-Object { Get-MgDirectoryRoleMember -DirectoryRoleId $_.Id } | Select-Object Id -Unique # Retrieve details about the members in these roles $PrivilegedUsers = $RoleMembers | ForEach-Object { Get-MgUser -UserId $_.Id -Property UserPrincipalName, DisplayName, Id } $Report = [System.Collections.Generic.List[Object]]::new() foreach ($Admin in $PrivilegedUsers) { $License = $null $License = (Get-MgUserLicenseDetail -UserId $Admin.id).SkuPartNumber - join \", \" $Object = [pscustomobject][ordered]@{ DisplayName = $Admin.DisplayName UserPrincipalName = $Admin.UserPrincipalName License = $License } $Report.Add($Object) } $Report 3. The output will display users assigned privileged roles alongside their assigned licenses. Additional manual assessment is required to determine if the licensing is appropriate for the user.", + "AdditionalInformation": "", + "DefaultValue": "N/A", + "References": "https://learn.microsoft.com/en-us/microsoft-365/enterprise/protect-your-global-administrator-accounts?view=o365-worldwide:https://learn.microsoft.com/en-us/entra/fundamentals/whatis#what-are-the-microsoft-entra-id-licenses:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference:https://learn.microsoft.com/en-us/microsoft-365/business-premium/m365bp-protect-admin-accounts?view=o365-worldwide:https://learn.microsoft.com/en-us/microsoft-365/enterprise/subscriptions-licenses-accounts-and-tenants-for-microsoft-cloud-offerings?view=o365-worldwide#licenses:https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-deployment-plan#principle-of-least-privilege" + } + ] + }, + { + "Id": "1.2.1", + "Description": "Microsoft 365 Groups is the foundational membership service that drives all teamwork across Microsoft 365. With Microsoft 365 Groups, you can give a group of people access to a collection of shared resources. When a new group is created in the Administration panel, the default privacy value of the group is \"Public\". (In this case, 'public' means accessible to the identities within the organization without requiring group owner authorization to join.) The recommended state is Microsoft 365 Groups are set to Private in the Administration panel. Note: Although there are several different group types, this recommendation concerns Microsoft 365 Groups specifically.", + "Checks": [ + "admincenter_groups_not_public_visibility" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.2 Teams & groups", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Microsoft 365 Groups is the foundational membership service that drives all teamwork across Microsoft 365. With Microsoft 365 Groups, you can give a group of people access to a collection of shared resources. When a new group is created in the Administration panel, the default privacy value of the group is \"Public\". (In this case, 'public' means accessible to the identities within the organization without requiring group owner authorization to join.) The recommended state is Microsoft 365 Groups are set to Private in the Administration panel. Note: Although there are several different group types, this recommendation concerns Microsoft 365 Groups specifically.", + "RationaleStatement": "If group privacy is not controlled, any user may access sensitive information, depending on the group they try to access. When the privacy value of a group is set to \"Public,\" users may access data related to this group (e.g. SharePoint) via three methods: 1. The Azure Portal: Users can add themselves to the public group via the Azure Portal; however, administrators are notified when users access the Portal. 2. Access Requests: Users can request to join the group via the Groups application in the Access Panel. This provides the user with immediate access to the group, even though they are required to send a message to the group owner when requesting to join. 3. SharePoint URL: Users can directly access a group via its SharePoint URL, which is usually guessable and can be found in the Groups application within the Access Panel.", + "ImpactStatement": "If the recommendation is applied, group owners could receive more access requests than usual, especially regarding groups originally meant to be public.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Teams & groups > Active teams & groups. 3. On the Active teams and groups page, select the group's name that is public. 4. On the popup groups name page, Select Settings. 5. Under Privacy, select Private.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Teams & groups > Active teams & groups. 3. On the Active teams and groups page, check that no groups have the status 'Public' in the privacy column. To audit using PowerShell: 1. Connect to the Microsoft Graph service using Connect-MgGraph -Scopes \"Group.Read.All\". 2. Run the following Microsoft Graph PowerShell command: $Groups = Get-MgGroup -All -Filter \"groupTypes/any(c:c eq 'Unified')\" ` -Property Id,DisplayName,Visibility,GroupTypes # Displays the groups to the console for review $Groups | ft Id,DisplayName,Visibility 3. Verify that Visibility is Private for each group.", + "AdditionalInformation": "", + "DefaultValue": "Public when created from the Administration portal; private otherwise.", + "References": "https://learn.microsoft.com/en-us/entra/identity/users/groups-self-service-management:https://learn.microsoft.com/en-us/microsoft-365/admin/create-groups/compare-groups?view=o365-worldwide" + } + ] + }, + { + "Id": "1.2.2", + "Description": "Shared mailboxes are used when multiple people need access to the same mailbox, such as a company information or support email address, reception desk, or other function that might be shared by multiple people. Users with permissions to the group mailbox can send as or send on behalf of the mailbox email address if the administrator has given that user permissions to do that. This is particularly useful for help and support mailboxes because users can send emails from \"Contoso Support\" or \"Building A Reception Desk.\" Shared mailboxes are created with a corresponding user account using a system generated password that is unknown at the time of creation. The recommended state is Sign in blocked for Shared mailboxes.", + "Checks": [ + "exchange_shared_mailbox_sign_in_disabled" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.2 Teams & groups", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Shared mailboxes are used when multiple people need access to the same mailbox, such as a company information or support email address, reception desk, or other function that might be shared by multiple people. Users with permissions to the group mailbox can send as or send on behalf of the mailbox email address if the administrator has given that user permissions to do that. This is particularly useful for help and support mailboxes because users can send emails from \"Contoso Support\" or \"Building A Reception Desk.\" Shared mailboxes are created with a corresponding user account using a system generated password that is unknown at the time of creation. The recommended state is Sign in blocked for Shared mailboxes.", + "RationaleStatement": "The intent of the shared mailbox is to only allow delegated access from other mailboxes. An admin could reset the password, or an attacker could potentially gain access to the shared mailbox allowing the direct sign-in to the shared mailbox and subsequently the sending of email from a sender that does not have a unique identity. To prevent this, block sign-in for the account that is associated with the shared mailbox.", + "ImpactStatement": "Blocking sign-in to shared mailboxes prevents direct authentication to these accounts. Authorized users can still access shared mailbox content through their own accounts using Outlook delegation or by being granted Send As/Send on Behalf permissions. This change strengthens security by ensuring shared mailboxes cannot serve as entry points for unauthorized access.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com/ 2. Click to expand Teams & groups and select Shared mailboxes. 3. Take note of all shared mailboxes. 4. Click to expand Users and select Active users. 5. Select a shared mailbox account to open its properties pane and then select Block sign-in. 6. Check the box for Block this user from signing in. 7. Repeat for any additional shared mailboxes. To remediate using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"User.ReadWrite.All\" 2. Connect to Exchange Online using Connect-ExchangeOnline. 3. To disable sign-in for a single account: $MBX = Get-EXOMailbox -Identity TestUser@example.com Update-MgUser -UserId $MBX.ExternalDirectoryObjectId -AccountEnabled:$false The following can be used block sign-in to all Shared Mailboxes: $MBX = Get-EXOMailbox -RecipientTypeDetails SharedMailbox $MBX | ForEach-Object { Update-MgUser -UserId $_.ExternalDirectoryObjectId - AccountEnabled:$false }", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com/ 2. Expand Teams & groups and select Shared mailboxes. 3. Take note of all shared mailboxes. 4. Expand Users and select Active users. 5. Select a shared mailbox account to open its properties pane, and review. 6. Verify that the text under the name reads Sign-in blocked. 7. Repeat for any additional shared mailboxes. Note: If sign-in is not blocked there will be an option to Block sign-in. This means the shared mailbox is out of compliance with this recommendation. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline 2. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"User.Read.All\" 3. Run the following PowerShell commands: $MBX = Get-EXOMailbox -RecipientTypeDetails SharedMailbox -ResultSize Unlimited $MBX | ForEach-Object { Get-MgUser -UserId $_.ExternalDirectoryObjectId ` -Property DisplayName, UserPrincipalName, AccountEnabled } | Format-Table DisplayName, UserPrincipalName, AccountEnabled 4. Ensure AccountEnabled is set to False for all Shared Mailboxes.", + "AdditionalInformation": "", + "DefaultValue": "AccountEnabled: True", + "References": "https://learn.microsoft.com/en-us/microsoft-365/admin/email/about-shared-mailboxes?view=o365-worldwide:https://learn.microsoft.com/en-us/microsoft-365/admin/email/create-a-shared-mailbox?view=o365-worldwide#block-sign-in-for-the-shared-mailbox-account:https://learn.microsoft.com/en-us/microsoft-365/enterprise/block-user-accounts-with-microsoft-365-powershell?view=o365-worldwide#block-individual-user-accounts" + } + ] + }, + { + "Id": "1.3.1", + "Description": "Microsoft cloud-only accounts have a pre-defined password policy that cannot be changed. The only items that can change are the number of days until a password expires and whether or not passwords expire at all.", + "Checks": [ + "admincenter_settings_password_never_expire" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.3 Settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft cloud-only accounts have a pre-defined password policy that cannot be changed. The only items that can change are the number of days until a password expires and whether or not passwords expire at all.", + "RationaleStatement": "Organizations such as NIST and Microsoft recommend against arbitrarily requiring users to change their passwords after a set period, unless there is evidence of compromise or the user has forgotten the password. This guidance applies even to single-factor (password-only) scenarios, as forced, periodic changes often lead to weaker passwords and reduced security. Additionally, this Benchmark advises implementing multi-factor authentication (MFA) for all accounts, which further diminishes the value of password expiration policies. Long-lived passwords can be further strengthened by enabling additional password protection features in Entra ID.", + "ImpactStatement": "When setting passwords not to expire it is important to have other controls in place to supplement this setting. See below for related recommendations and user guidance. - Ban common passwords. - Educate users to not reuse organization passwords anywhere else. - Enforce Multi-Factor Authentication registration for all users.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org Settings. 3. Click on Security & privacy. 4. Check the Set passwords to never expire (recommended) box. 5. Click Save. To remediate using PowerShell: 1. Connect to the Microsoft Graph service using Connect-MgGraph -Scopes \"Domain.ReadWrite.All\". 2. Run the following Microsoft Graph PowerShell command: Update-MgDomain -DomainId -PasswordValidityPeriodInDays 2147483647", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org Settings. 3. Click on Security & privacy. 4. Select Password expiration policy and verify that Set passwords to never expire (recommended) has been checked. To audit using PowerShell: 1. Connect to the Microsoft Graph service using Connect-MgGraph -Scopes \"Domain.Read.All\". 2. Run the following Microsoft Online PowerShell command: Get-MgDomain | ft id,PasswordValidityPeriodInDays 3. Verify the value returned for valid domains is 2147483647", + "AdditionalInformation": "", + "DefaultValue": "If the property is not set, a default value of 90 days will be used", + "References": "https://pages.nist.gov/800-63-3/sp800-63b.html:https://www.cisecurity.org/white-papers/cis-password-policy-guide/:https://learn.microsoft.com/en-us/microsoft-365/admin/misc/password-policy-recommendations?view=o365-worldwide" + } + ] + }, + { + "Id": "1.3.2", + "Description": "Idle session timeout allows the configuration of a setting which will timeout inactive users after a pre-determined amount of time. When a user reaches the set idle timeout session, they'll get a notification that they're about to be signed out. They must choose to stay signed in or they'll be automatically signed out of all Microsoft 365 web apps. Combined with a Conditional Access rule this will only impact unmanaged devices. A managed device is considered a device managed by Intune MDM or joined to a domain (Entra ID or Hybrid joined). The following Microsoft 365 web apps are supported. - Outlook Web App - OneDrive - SharePoint - Microsoft Fabric - Microsoft365.com and other start pages - Microsoft 365 web apps (Word, Excel, PowerPoint) - Microsoft 365 Admin Center - M365 Defender Portal - Microsoft Purview Compliance Portal The recommended setting is 3 hours (or less) for unmanaged devices. Note: Idle session timeout doesn't affect Microsoft 365 desktop and mobile apps.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.3 Settings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Idle session timeout allows the configuration of a setting which will timeout inactive users after a pre-determined amount of time. When a user reaches the set idle timeout session, they'll get a notification that they're about to be signed out. They must choose to stay signed in or they'll be automatically signed out of all Microsoft 365 web apps. Combined with a Conditional Access rule this will only impact unmanaged devices. A managed device is considered a device managed by Intune MDM or joined to a domain (Entra ID or Hybrid joined). The following Microsoft 365 web apps are supported. - Outlook Web App - OneDrive - SharePoint - Microsoft Fabric - Microsoft365.com and other start pages - Microsoft 365 web apps (Word, Excel, PowerPoint) - Microsoft 365 Admin Center - M365 Defender Portal - Microsoft Purview Compliance Portal The recommended setting is 3 hours (or less) for unmanaged devices. Note: Idle session timeout doesn't affect Microsoft 365 desktop and mobile apps.", + "RationaleStatement": "Ending idle sessions through an automatic process can help protect sensitive company data and will add another layer of security for end users who work on unmanaged devices that can potentially be accessed by the public. Unauthorized individuals onsite or remotely can take advantage of systems left unattended over time. Automatic timing out of sessions makes this more difficult.", + "ImpactStatement": "If step 2 in the Audit/Remediation procedure is left out, then there is no issue with this from a security standpoint. However, it will require users on trusted devices to sign in more frequently which could result in credential prompt fatigue. Users don't get signed out in these cases: - If they get single sign-on (SSO) into the web app from the device joined account. - If they selected Stay signed in at the time of sign-in. For more info on hiding this option for your organization, see Add branding to your organization's sign-in page. - If they're on a managed device, that is compliant or joined to a domain and using a supported browser, like Microsoft Edge, or Google Chrome with the Microsoft Single Sign On extension. Note: Idle session timeout also affects the Azure Portal idle timeout if this is not explicitly set to a different timeout. The Azure Portal idle timeout applies to all kinds of devices, not just unmanaged. See: change the directory timeout setting admin", + "RemediationProcedure": "Step 1 - Configure Idle session timeout: To remediate using the UI: 1. Navigate to the Microsoft 365 admin center https://admin.microsoft.com/. 2. Expand Settings > Org settings. 3. Click Security & Privacy tab. 4. Select Idle session timeout. 5. Check the box Turn on to set the period of inactivity for users to be signed off of Microsoft 365 web apps 6. Set a maximum value of 3 hours. 7. Click save. Step 2 - Ensure the Conditional Access policy is in place: To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Protect > Conditional Access. 3. Click New policy and give the policy a name. o Select Users > All users. o Select Cloud apps or actions > Select apps and select Office 365 o Select Conditions > Client apps > Yes check only Browser unchecking all other boxes. o Select Sessions and check Use app enforced restrictions. 4. Set Enable policy to On and click Create. Note: To ensure that idle timeouts affect only unmanaged devices, both steps 1 and 2 must be completed. Otherwise managed devices will also be impacted by the timeout policy.", + "AuditProcedure": "Step 1 - Ensure Idle session timeout is configured: To audit using the UI: 1. Navigate to the Microsoft 365 admin center https://admin.microsoft.com/. 2. Expand Settings > Org settings. 3. Click Security & Privacy tab. 4. Select Idle session timeout. 5. Verify that Turn on to set the period of inactivity for users to be signed off of Microsoft 365 web apps is set to 3 hours (or less). To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\": 2. Run the following script: $TimeoutPolicy = Get-MgPolicyActivityBasedTimeoutPolicy $BenchmarkTimeSpan = [TimeSpan]::Parse('03:00:00') # 3 hours if ($TimeoutPolicy) { $PolicyDefinition = $TimeoutPolicy.Definition | ConvertFrom-Json $Timeout = $PolicyDefinition.ActivityBasedTimeoutPolicy.ApplicationPolicies[0].WebSessio nIdleTimeout $TimeSpan = [TimeSpan]::Parse($Timeout) $TimeoutReadable = \"{0} days, {1} hours, {2} minutes\" ` -f $TimeSpan.Days, $TimeSpan.Hours, $TimeSpan.Minutes if ($TimeSpan -le $BenchmarkTimeSpan) { Write-Host \"** PASS ** Timeout is set to $TimeoutReadable.\" } else { Write-Host \"** FAIL ** Timeout is too long. It is set to $TimeoutReadable.\" } } else { Write-Host \"** FAIL **: Idle session timeout is not configured.\" } 3. Verify the policy exists and is 3 hours or less. Step 2 - Ensure the Conditional Access policy is in place: To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Protect > Conditional Access. 3. Inspect existing conditional access rules for one that meets the below conditions: o Users or agents (Preview) is set to include All users. o Cloud apps or actions > Select apps is set to Office 365. o Conditions > Client apps is Browser and nothing else. o Session is set to Use app enforced restrictions. o Enable Policy is set to On To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\": 2. Run the following script: $Caps = Get-MgIdentityConditionalAccessPolicy -All | Where-Object { $_.SessionControls.ApplicationEnforcedRestrictions.IsEnabled } $CapReport = [System.Collections.Generic.List[Object]]::new() # Filter to policies with \"Use app enforced restrictions\" enabled # Loop through policies and generate a per policy report. foreach ($policy in $Caps) { $Name = $policy.DisplayName $Users = $policy.Conditions.Users.IncludeUsers $Targets = $policy.Conditions.Applications.IncludeApplications $ClientApps = $policy.Conditions.ClientAppTypes $Restrictions = $policy.SessionControls.ApplicationEnforcedRestrictions.IsEnabled $State = $policy.State $CountPass = $Targets.count -eq 1 -and $ClientApps.count -eq 1 $Pass = $Targets -eq 'Office365' -and $ClientApps -eq 'browser' -and $Restrictions -and $CountPass -and $State -eq 'enabled' $obj = [PSCustomObject]@{ DisplayName = $Name AuditState = if ($Pass) { \"PASS\" } else { \"FAIL\" } IncludeUsers = $Users IncludeApplications = $Targets ClientAppTypes = $ClientApps AppEnforcedRestrictions = $Restrictions State = $State } $CapReport.Add($obj) } if ($Caps) { $CapReport } else { Write-Host \"** FAIL **: There are no qualifying conditional access policies.\" } 3. The script will output qualifying Conditional Access Policies. If one policy passes, then the recommendation passes. A passing policy will have the following properties: DisplayName : (CIS) Idle timeout for unmanaged AuditState : PASS IncludeUsers : {All} # IncludeUsers not currently scored IncludeApplications : {Office365} ClientAppTypes : {browser} AppEnforcedRestrictions : True State : enabled Note: Both steps 1 and 2 must pass audit checks in order for the recommendation to pass as a whole.", + "AdditionalInformation": "According to Microsoft idle session timeout isn't supported when third party cookies are disabled in the browser. Users won't see any sign-out prompts.", + "DefaultValue": "Not configured. (Idle sessions will not timeout.)", + "References": "https://learn.microsoft.com/en-us/microsoft-365/admin/manage/idle-session-timeout-web-apps?view=o365-worldwide" + } + ] + }, + { + "Id": "1.3.3", + "Description": "External calendar sharing allows an administrator to enable the ability for users to share calendars with anyone outside of the organization. Outside users will be sent a URL that can be used to view the calendar.", + "Checks": [ + "admincenter_external_calendar_sharing_disabled" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.3 Settings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "External calendar sharing allows an administrator to enable the ability for users to share calendars with anyone outside of the organization. Outside users will be sent a URL that can be used to view the calendar.", + "RationaleStatement": "Attackers often spend time learning about organizations before launching an attack. Publicly available calendars can help attackers understand organizational relationships and determine when specific users may be more vulnerable to an attack, such as when they are traveling.", + "ImpactStatement": "This functionality is not widely used. As a result, it is unlikely that implementation of this setting will cause an impact to most users. Users that do utilize this functionality are likely to experience a minor inconvenience when scheduling meetings or synchronizing calendars with people outside the tenant.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings select Org settings. 3. In the Services section click Calendar. 4. Uncheck Let your users share their calendars with people outside of your organization who have Office 365 or Exchange. 5. Click Save. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following Exchange Online PowerShell command: Set-SharingPolicy -Identity \"Default Sharing Policy\" -Enabled $False", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org settings. 3. In the Services section click Calendar. 4. Verify that Let your users share their calendars with people outside of your organization who have Office 365 or Exchange is unchecked. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following Exchange Online PowerShell command: Get-SharingPolicy -Identity \"Default Sharing Policy\" | ft Name,Enabled 3. Verify that Enabled is set to False", + "AdditionalInformation": "The following script can be used to audit any mailboxes that might be sharing calendars prior to disabling the feature globally: $mailboxes = Get-Mailbox -ResultSize Unlimited foreach ($mailbox in $mailboxes) { # Get the name of the default calendar folder (depends on the mailbox's language) $calendarFolder = [string](Get-ExoMailboxFolderStatistics $mailbox.PrimarySmtpAddress -FolderScope Calendar| Where-Object { $_.FolderType -eq 'Calendar' }).Name # Get users calendar folder settings for their default Calendar folder # calendar has the format identity:\\ $calendar = Get-MailboxCalendarFolder -Identity \"$($mailbox.PrimarySmtpAddress):\\$calendarFolder\" if ($calendar.PublishEnabled) { Write-Host -ForegroundColor Yellow \"Calendar publishing is enabled for $($mailbox.PrimarySmtpAddress) on $($calendar.PublishedCalendarUrl)\" } }", + "DefaultValue": "Enabled (True)", + "References": "https://learn.microsoft.com/en-us/microsoft-365/admin/manage/share-calendars-with-external-users?view=o365-worldwide" + } + ] + }, + { + "Id": "1.3.4", + "Description": "By default, users can install add-ins in their Microsoft Word, Excel, and PowerPoint applications, allowing data access within the application. Do not allow users to install add-ins in Word, Excel, or PowerPoint.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.3 Settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "By default, users can install add-ins in their Microsoft Word, Excel, and PowerPoint applications, allowing data access within the application. Do not allow users to install add-ins in Word, Excel, or PowerPoint.", + "RationaleStatement": "Attackers commonly use vulnerable and custom-built add-ins to access data in user applications. While allowing users to install add-ins by themselves does allow them to easily acquire useful add-ins that integrate with Microsoft applications, it can represent a risk if not used and monitored carefully. Disabling future users' ability to install add-ins in Microsoft Word, Excel, or PowerPoint helps reduce your threat-surface and mitigate this risk.", + "ImpactStatement": "Implementation of this change will impact both end users and administrators. End users will not be able to install add-ins that they may want to install.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org settings. 3. In Services select User owned apps and services. 4. Uncheck Let users access the Office Store and Let users start trials on behalf of your organization. 5. Click Save. To remediate using PowerShell 1. Connect to the Microsoft Graph service using Connect-MgGraph -Scopes \"OrgSettings-AppsAndServices.ReadWrite.All\". 2. Run the following Microsoft Graph PowerShell commands: $uri = \"https://graph.microsoft.com/beta/admin/appsAndServices\" $body = @{ \"Settings\" = @{ \"isAppAndServicesTrialEnabled\" = $false \"isOfficeStoreEnabled\" = $false } } | ConvertTo-Json Invoke-MgGraphRequest -Method PATCH -Uri $uri -Body $body", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org settings. 3. In Services select User owned apps and services. 4. Verify that Let users access the Office Store and Let users start trials on behalf of your organization are not checked. To Audit using PowerShell: 1. Connect to the Microsoft Graph service using Connect-MgGraph -Scopes \"OrgSettings-AppsAndServices.Read.All\". 2. Run the following Microsoft Graph PowerShell command: $Uri = \"https://graph.microsoft.com/beta/admin/appsAndServices/settings\" Invoke-MgGraphRequest -Uri $Uri 3. Verify both isOfficeStoreEnabled and isAppAndServicesTrialEnabled are False.", + "AdditionalInformation": "", + "DefaultValue": "Let users access the Office Store is Checked Let users start trials on behalf of your organization is Checked", + "References": "https://learn.microsoft.com/en-us/microsoft-365/admin/manage/manage-addins-in-the-admin-center?view=o365-worldwide#manage-add-in-downloads-by-turning-onoff-the-office-store-across-all-apps-except-outlook" + } + ] + }, + { + "Id": "1.3.5", + "Description": "Microsoft Forms can be used for phishing attacks by asking personal or sensitive information and collecting the results. Microsoft 365 has built-in protection that will proactively scan for phishing attempt in forms such personal information request.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.3 Settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft Forms can be used for phishing attacks by asking personal or sensitive information and collecting the results. Microsoft 365 has built-in protection that will proactively scan for phishing attempt in forms such personal information request.", + "RationaleStatement": "Enabling internal phishing protection for Microsoft Forms will prevent attackers using forms for phishing attacks by asking personal or other sensitive information and URLs.", + "ImpactStatement": "If potential phishing was detected, the form will be temporarily blocked and cannot be distributed, and response collection will not happen until it is unblocked by the administrator or keywords were removed by the creator.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org settings. 3. Under Services select Microsoft Forms. 4. Click the checkbox labeled Add internal phishing protection under Phishing protection. 5. Click Save. To remediate using PowerShell 1. Connect to the Microsoft Graph service using Connect-MgGraph -Scopes \"OrgSettings-AppsAndServices.ReadWrite.All\". 2. Run the following Microsoft Graph PowerShell commands: $uri = 'https://graph.microsoft.com/beta/admin/forms/settings' $body = @{ \"isInOrgFormsPhishingScanEnabled\" = $true } | ConvertTo-Json Invoke-MgGraphRequest -Method PATCH -Uri $uri -Body $body", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org settings. 3. Under Services select Microsoft Forms. 4. Verify the checkbox labeled Add internal phishing protection is checked under Phishing protection. To Audit using PowerShell: 1. Connect to the Microsoft Graph service using Connect-MgGraph -Scopes \"OrgSettings-Forms.Read.All\". 2. Run the following Microsoft Graph PowerShell commands: $uri = 'https://graph.microsoft.com/beta/admin/forms/settings' Invoke-MgGraphRequest -Uri $uri | select isInOrgFormsPhishingScanEnabled 3. Verify that isInOrgFormsPhishingScanEnabled is 'True'.", + "AdditionalInformation": "", + "DefaultValue": "Internal Phishing Protection is enabled.", + "References": "https://learn.microsoft.com/en-US/microsoft-forms/administrator-settings-microsoft-forms:https://learn.microsoft.com/en-US/microsoft-forms/review-unblock-forms-users-detected-blocked-potential-phishing" + } + ] + }, + { + "Id": "1.3.6", + "Description": "Customer Lockbox is a security feature that provides an additional layer of control and transparency to customer data in Microsoft 365. It offers an approval process for Microsoft support personnel to access organization data and creates an audited trail to meet compliance requirements.", + "Checks": [ + "admincenter_organization_customer_lockbox_enabled" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.3 Settings", + "Profile": "E5 Level 2", + "AssessmentStatus": "Automated", + "Description": "Customer Lockbox is a security feature that provides an additional layer of control and transparency to customer data in Microsoft 365. It offers an approval process for Microsoft support personnel to access organization data and creates an audited trail to meet compliance requirements.", + "RationaleStatement": "Enabling this feature protects organizational data against data spillage and exfiltration.", + "ImpactStatement": "Administrators will need to grant Microsoft access to the tenant environment prior to a Microsoft engineer accessing the environment for support or troubleshooting.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org settings. 3. Select Security & privacy tab. 4. Click Customer lockbox. 5. Check the box Require approval for all data access requests. 6. Click Save. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-OrganizationConfig -CustomerLockBoxEnabled $true", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org settings. 3. Select Security & privacy tab. 4. Click Customer lockbox. 5. Verify the box labeled Require approval for all data access requests is checked. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-OrganizationConfig | Select-Object CustomerLockBoxEnabled 3. Verify the value is set to True.", + "AdditionalInformation": "", + "DefaultValue": "Require approval for all data access requests - Unchecked CustomerLockboxEnabled - False", + "References": "https://learn.microsoft.com/en-us/purview/customer-lockbox-requests#turn-customer-lockbox-requests-on-or-off" + } + ] + }, + { + "Id": "1.3.7", + "Description": "Third-party storage can be enabled for users in Microsoft 365, allowing them to store and share documents using services such as Dropbox, alongside OneDrive and team sites. Ensure Microsoft 365 on the web third-party storage services are restricted.", + "Checks": [ + "exchange_mailbox_policy_additional_storage_restricted" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.3 Settings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Third-party storage can be enabled for users in Microsoft 365, allowing them to store and share documents using services such as Dropbox, alongside OneDrive and team sites. Ensure Microsoft 365 on the web third-party storage services are restricted.", + "RationaleStatement": "By using external storage services an organization may increase the risk of data breaches and unauthorized access to confidential information. Additionally, third-party services may not adhere to the same security standards as the organization, making it difficult to maintain data privacy and security.", + "ImpactStatement": "Impact associated with this change is highly dependent upon current practices in the tenant. If users do not use other storage providers, then minimal impact is likely. However, if users do regularly utilize providers outside of the tenant this will affect their ability to continue to do so.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com 2. Go to Settings > Org Settings > Services > Microsoft 365 on the web 3. Uncheck Let users open files stored in third-party storage services in Microsoft 365 on the web To remediate using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Application.ReadWrite.All\" 2. Run the following script: $SP = Get-MgServicePrincipal -Filter \"appId eq 'c1f33bc0-bdb4-4248-ba9b- 096807ddb43e'\" # If the service principal doesn't exist then create it first. if (-not $SP) { $SP = New-MgServicePrincipal -AppId \"c1f33bc0-bdb4-4248-ba9b- 096807ddb43e\" } Update-MgServicePrincipal -ServicePrincipalId $SP.Id -AccountEnabled:$false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com 2. Go to Settings > Org Settings > Services > Microsoft 365 on the web 3. Verify that Let users open files stored in third-party storage services in Microsoft 365 on the web is not checked. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Application.Read.All\". 2. Run the following script: $SP = Get-MgServicePrincipal -Filter \"appId eq 'c1f33bc0-bdb4-4248-ba9b- 096807ddb43e'\" if ((-not $SP) -or $SP.AccountEnabled) { Write-Host \"Audit Result: ** FAIL **\" } else { Write-Host \"Audit Result: ** PASS **\" } 3. Verify that AccountEnabled is False. Note: The check will also fail if the Service Principal does not exist as users will still be able to open files stored in third-party storage services in Microsoft 365 on the web.", + "AdditionalInformation": "", + "DefaultValue": "Enabled - Users are able to open files stored in third-party storage services", + "References": "https://learn.microsoft.com/en-us/microsoft-365/admin/setup/set-up-file-storage-and-sharing?view=o365-worldwide#enable-or-disable-third-party-storage-services" + } + ] + }, + { + "Id": "1.3.8", + "Description": "Sway is a Microsoft 365 app that lets organizations create interactive, web-based presentations using images, text, videos and other media. Its design engine simplifies the process, allowing for quick customization. Presentations can then be shared via a link. This setting controls user Sway sharing capability, both within and outside of the organization. By default, Sway is enabled for everyone in the organization.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.3 Settings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Manual", + "Description": "Sway is a Microsoft 365 app that lets organizations create interactive, web-based presentations using images, text, videos and other media. Its design engine simplifies the process, allowing for quick customization. Presentations can then be shared via a link. This setting controls user Sway sharing capability, both within and outside of the organization. By default, Sway is enabled for everyone in the organization.", + "RationaleStatement": "Disable external sharing of Sway documents that can contain sensitive information to prevent accidental or arbitrary data leaks.", + "ImpactStatement": "Interactive reports, presentations, newsletters, and other items created in Sway will not be shared outside the organization by users.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org settings. 3. Under Services select Sway o Uncheck: Let people in your organization share their sways with people outside your organization. 4. Click Save.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org settings. 3. Under Services select Sway. 4. Verify that under Sharing, the following is not checked: o Let people in your organization share their sways with people outside your organization.", + "AdditionalInformation": "", + "DefaultValue": "Let people in your organization share their sways with people outside your organization - Enabled", + "References": "https://support.microsoft.com/en-us/office/administrator-settings-for-sway-d298e79b-b6ab-44c6-9239-aa312f5784d4:https://learn.microsoft.com/en-us/office365/servicedescriptions/microsoft-sway-service-description" + } + ] + }, + { + "Id": "1.3.9", + "Description": "Shared Bookings allows you to invite your team members and create booking pages and let your customers book time with you and your team. It contains various settings to define services, manage staff members, configure schedules and availability, business hours and customize how appointments are scheduled. These pages can be customized to fit the diverse needs of your organization. It is an extension of Person Bookings. The recommended state is to restrict the OwaMailboxPolicy-Default policy or disable at the organization level.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.3 Settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Shared Bookings allows you to invite your team members and create booking pages and let your customers book time with you and your team. It contains various settings to define services, manage staff members, configure schedules and availability, business hours and customize how appointments are scheduled. These pages can be customized to fit the diverse needs of your organization. It is an extension of Person Bookings. The recommended state is to restrict the OwaMailboxPolicy-Default policy or disable at the organization level.", + "RationaleStatement": "Shared Bookings pages can be exploited by threat actors to impersonate legitimate users using convincing internal email addresses. A compromised low-privilege account could be used to mimic high-profile identities (e.g., the CEO) and bypass impersonation filters to initiate fraudulent actions like fund transfers. Additionally, attackers may create authoritative-looking addresses (e.g., admin@, hostmaster@) to conduct social engineering attacks on external parties aimed at the transfer of infrastructure control. To reduce this risk, access to Shared Bookings should be limited to users with a clear business need and subject to monitoring and governance.", + "ImpactStatement": "Disabling Shared Bookings will limit users' ability to create self-service scheduling pages, which may reduce convenience for teams that rely on automated meeting coordination. Approved users will need to be added to a separate OWA Policy which will increase administrative overhead. Note: Before modifying the default owa policy, ensure that any users who rely on Shared Bookings are assigned a separate policy that explicitly allows its use. This will help prevent unintended service disruptions.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-OwaMailboxPolicy \"OwaMailboxPolicy-Default\" - BookingsMailboxCreationEnabled $false Optionally: For a more restrictive state Bookings can be disabled at the organization level 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following command: Set-OrganizationConfig -BookingsEnabled $false Note: Disabling Bookings at the tenant (organization) level will be more impactful to end users and is not required for compliance.", + "AuditProcedure": "Ensure Shared Bookings is turned off in the OWA Default policy. If booking is disabled at the tenant (OrganizationConfig) level this is also a compliant state. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following command: Get-OwaMailboxPolicy -Identity OwaMailboxPolicy-Default | fl BookingsMailboxCreationEnabled 3. Verify that BookingsMailboxCreationEnabled is set to False. Optionally: If Bookings is disabled at the organization level, this is also considered a compliant state. 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following command: Get-OrganizationConfig | fl BookingsEnabled 3. If BookingsEnabled is set to False, the organization is using a more restrictive and compliant configuration. In this case changing the default OWA policy would not be required for compliance.", + "AdditionalInformation": "", + "DefaultValue": "BookingsMailboxCreationEnabled : True (OwaMailboxPolicy-Default) BookingsEnabled : True", + "References": "https://learn.microsoft.com/en-us/microsoft-365/bookings/turn-bookings-on-or-off?view=o365-worldwide:https://techcommunity.microsoft.com/blog/office365businessappsblog/enhancing-security-in-microsoft-bookings-best-practices-for-admins/4382447:https://learn.microsoft.com/en-us/microsoft-365/bookings/best-practices-shared-bookings?view=o365-worldwide&source=recommendations:https://www.cyberis.com/article/microsoft-bookings-facilitating-impersonation" + } + ] + }, + { + "Id": "2.1.1", + "Description": "Enabling Safe Links policy for Office applications allows URL's that exist inside of Office documents and email applications opened by Office, Office Online and Office mobile to be processed against Defender for Office time-of-click verification and rewritten if required. Note: E5 Licensing includes a number of Built-in Protection policies. When auditing policies note which policy you are viewing, and keep in mind CIS recommendations often extend the Default or Built-in Policies provided by MS. In order to Pass the highest priority policy must match all settings recommended.", + "Checks": [ + "defender_safelinks_policy_enabled" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E5 Level 2", + "AssessmentStatus": "Automated", + "Description": "Enabling Safe Links policy for Office applications allows URL's that exist inside of Office documents and email applications opened by Office, Office Online and Office mobile to be processed against Defender for Office time-of-click verification and rewritten if required. Note: E5 Licensing includes a number of Built-in Protection policies. When auditing policies note which policy you are viewing, and keep in mind CIS recommendations often extend the Default or Built-in Policies provided by MS. In order to Pass the highest priority policy must match all settings recommended.", + "RationaleStatement": "Safe Links for Office applications extends phishing protection to documents and emails that contain hyperlinks, even after they have been delivered to a user.", + "ImpactStatement": "User impact associated with this change is minor - users may experience a very short delay when clicking on URLs in Office documents before being directed to the requested site. Users should be informed of the change as, in the event a link is unsafe and blocked, they will receive a message that it has been blocked.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com 2. Under Email & collaboration select Policies & rules 3. Select Threat policies then Safe Links 4. Click on +Create 5. Name the policy then click Next 6. In Domains select all valid domains for the organization and Next 7. Ensure the following URL & click protection settings are defined: Email o Checked On: Safe Links checks a list of known, malicious links when users click links in email. URLs are rewritten by default o Checked Apply Safe Links to email messages sent within the organization o Checked Apply real-time URL scanning for suspicious links and links that point to files o Checked Wait for URL scanning to complete before delivering the message o Unchecked Do not rewrite URLs, do checks via Safe Links API only. Teams o Checked On: Safe Links checks a list of known, malicious links when users click links in Microsoft Teams. URLs are not rewritten Office 365 Apps o Checked On: Safe Links checks a list of known, malicious links when users click links in Microsoft Office apps. URLs are not rewritten Click protection settings o Checked Track user clicks o Unchecked Let users click through the original URL o There is no recommendation for organization branding. 8. Click Next twice and finally Submit To remediate using PowerShell: 1. Connect using Connect-ExchangeOnline. 2. Run the following PowerShell script to create a policy at highest priority that will apply to all valid domains on the tenant: # Create the Policy $params = @{ Name = \"CIS SafeLinks Policy\" EnableSafeLinksForEmail = $true EnableSafeLinksForTeams = $true EnableSafeLinksForOffice = $true TrackClicks = $true AllowClickThrough = $false ScanUrls = $true EnableForInternalSenders = $true DeliverMessageAfterScan = $true DisableUrlRewrite = $false } New-SafeLinksPolicy @params # Create the rule for all users in all valid domains and associate with Policy New-SafeLinksRule -Name \"CIS SafeLinks\" -SafeLinksPolicy \"CIS SafeLinks Policy\" -RecipientDomainIs (Get-AcceptedDomain).Name -Priority 0", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com 2. Under Email & collaboration select Policies & rules 3. Select Threat policies then Safe Links 4. Inspect each policy and attempt to identify one that matches the parameters outlined below. 5. Scroll down the pane and click on Edit Protection settings (Global Readers will look for on or off values) 6. Verify that the following protection settings are set as outlined: Email o Checked On: Safe Links checks a list of known, malicious links when users click links in email. URLs are rewritten by default o Checked Apply Safe Links to email messages sent within the organization o Checked Apply real-time URL scanning for suspicious links and links that point to files o Checked Wait for URL scanning to complete before delivering the message o Unchecked Do not rewrite URLs, do checks via Safe Links API only. Teams o Checked On: Safe Links checks a list of known, malicious links when users click links in Microsoft Teams. URLs are not rewritten Office 365 Apps o Checked On: Safe Links checks a list of known, malicious links when users click links in Microsoft Office apps. URLs are not rewritten Click protection settings oChecked Track user clicks oUnchecked Let users click through the original URL 7. There is no recommendation for organization branding. 8. Click close To audit using PowerShell: 1. Connect using Connect-ExchangeOnline. 2. Run the following to output properties from all Safe Links policies: $params = @( 'Identity', 'EnableSafeLinksForEmail', 'EnableSafeLinksForTeams', 'EnableSafeLinksForOffice', 'TrackClicks', 'AllowClickThrough', 'ScanUrls', 'EnableForInternalSenders', 'DeliverMessageAfterScan', 'DisableUrlRewrite' ) Get-SafeLinksPolicy | Select-Object -Property $Params 3. Verify there is at least one policy that matches the properties and values below: Identity : EnableSafeLinksForEmail : True EnableSafeLinksForTeams : True EnableSafeLinksForOffice : True TrackClicks : True AllowClickThrough : False ScanUrls : True EnableForInternalSenders : True DeliverMessageAfterScan : True DisableUrlRewrite : False", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/defender-office-365/safe-links-policies-configure?view=o365-worldwide:https://learn.microsoft.com/en-us/powershell/module/exchange/set-safelinkspolicy?view=exchange-ps:https://learn.microsoft.com/en-us/defender-office-365/preset-security-policies?view=o365-worldwide" + } + ] + }, + { + "Id": "2.1.2", + "Description": "The Common Attachment Types Filter is a setting within Exchange Online Protection's anti-malware policy that blocks inbound and outbound email messages containing attachments of specified file types. When enabled, messages with attachments matching the blocked extensions are quarantined before delivery. Microsoft maintains a default set of file types considered high risk; organizations may also add custom extensions to the list. The recommended state is Enable the common attachments filter set to On, on the default anti-malware policy, with the default list of blocked file types.", + "Checks": [ + "defender_malware_policy_common_attachments_filter_enabled" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "The Common Attachment Types Filter is a setting within Exchange Online Protection's anti-malware policy that blocks inbound and outbound email messages containing attachments of specified file types. When enabled, messages with attachments matching the blocked extensions are quarantined before delivery. Microsoft maintains a default set of file types considered high risk; organizations may also add custom extensions to the list. The recommended state is Enable the common attachments filter set to On, on the default anti-malware policy, with the default list of blocked file types.", + "RationaleStatement": "Email is a primary delivery vector for malware, including ransomware, trojans, and remote access tools distributed via executable, script, and installer file formats. The Common Attachment Types Filter blocks delivery of file types that have no legitimate business use in email but are routinely weaponized (such as .exe, .vbs, .bat, .msi), and similar formats. Enforcing this filter at the gateway reduces the attack surface before any client-side or endpoint control has the opportunity to respond.", + "ImpactStatement": "Emails containing attachments with blocked extensions, including those sent by trusted internal senders, will be quarantined and not delivered. Some file types in the default block list may be used legitimately in some IT workflows. Administrators who need to permit specific extensions for specific users or groups should create a scoped custom anti-malware policy with a higher priority than the Default policy rather than modifying the Default policy's file type list.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under polices select Anti-malware and click on the Default (Default) policy. 5. On the Policy page that appears on the right hand pane scroll to the bottom and click on Edit protection settings, check the Enable the common attachments filter. o If any of the default file types are missing click Select file types and add the missing file types in. o Reference the Default Value section of this document for the list of extensions that should be blocked. 6. Click Save to save the changes. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following to enable the common attachment filter: Set-MalwareFilterPolicy -Identity Default -EnableFileFilter $true 3. Use Set-MalwareFilterPolicy -Identity Default with the -FileTypes parameter to add any missing file types from the default list. o FileTypes accepts an array of strings. o To avoid using it destructively, first retrieve the existing list of file types using Get-MalwareFilterPolicy and append any missing file types to the list before using Set-MalwareFilterPolicy to update the policy.", + "AuditProcedure": "Note: The following procedures audit only the Default anti-malware policy. Auditing custom policies is not required for compliance and is discretionary based on the organization's needs. To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under Policies select Anti-malware and click on the Default (Default) policy. 5. On the policy page that appears on the righthand pane, under Protection settings, verify that the Enable the common attachments filter has the value of On. 6. Click on Edit protection settings to view the list of file types that are blocked by the common attachment filter. Verify that the list of file types contains at least the 53 file types found in the Default Value section of this document. Note: Verifying the complete file type list via the UI requires manual comparison against the default extensions listed in the Default Value section of this document. Auditors who require a programmatic comparison should use the PowerShell method. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following Exchange Online PowerShell command: Get-MalwareFilterPolicy -Identity Default 3. Verify that the EnableFileFilter property has the value of True. 4. Verify that the FileTypes property contains at least default list of 53 file types found in the Default Value section of this document.", + "AdditionalInformation": "", + "DefaultValue": "EnableFileFilter : True Default extensions: [ \"ani\", \"apk\", \"app\", \"appx\", \"arj\", \"bat\", \"cab\", \"cmd\", \"com\", \"deb\", \"dex\", \"dll\", \"docm\", \"elf\", \"exe\", \"hta\", \"img\", \"iso\", \"jar\", \"jnlp\", \"kext\", \"lha\", \"lib\", \"library\", \"lnk\", \"lzh\", \"macho\", \"msc\", \"msi\", \"msix\", \"msp\", \"mst\", \"pif\", \"ppa\", \"ppam\", \"reg\", \"rev\", \"scf\", \"scr\", \"sct\", \"sys\", \"uif\", \"vb\", \"vbe\", \"vbs\", \"vxd\", \"wsc\", \"wsf\", \"wsh\", \"xll\", \"xz\", \"z\", \"ace\" ]", + "References": "https://learn.microsoft.com/en-us/powershell/module/exchange/get-malwarefilterpolicy?view=exchange-ps:https://learn.microsoft.com/en-us/defender-office-365/anti-malware-policies-configure?view=o365-worldwide" + } + ] + }, + { + "Id": "2.1.3", + "Description": "Exchange Online Protection (EOP) is Microsoft's cloud-based filtering service that protects organizations against spam, malware, and other email threats. EOP is included in all Microsoft 365 organizations with Exchange Online mailboxes. EOP uses flexible anti-malware policies for malware protection settings. These policies can be set to notify Admins of malicious activity.", + "Checks": [ + "defender_malware_policy_notifications_internal_users_malware_enabled" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Exchange Online Protection (EOP) is Microsoft's cloud-based filtering service that protects organizations against spam, malware, and other email threats. EOP is included in all Microsoft 365 organizations with Exchange Online mailboxes. EOP uses flexible anti-malware policies for malware protection settings. These policies can be set to notify Admins of malicious activity.", + "RationaleStatement": "This setting alerts administrators that an internal user sent a message that contained malware. This may indicate an account or machine compromise that would need to be investigated.", + "ImpactStatement": "Notification of account with potential issues should not have an impact on the user.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand E-mail & Collaboration > Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under Policies select Anti-malware. 5. Click on the Default (Default) policy. 6. Click on Edit protection settings and change the settings for Notify an admin about undelivered messages from internal senders to On and enter the email address of the administrator who should be notified under Administrator email address. 7. Click Save. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following command: Set-MalwareFilterPolicy -Identity '{Identity Name}' - EnableInternalSenderAdminNotifications $True -InternalSenderAdminAddress {admin@domain1.com} Note: Audit and Remediation guidance may focus on the Default policy however, if a Custom Policy exists in the organization's tenant, then ensure the setting is set as outlined in the highest priority policy listed.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand E-mail & Collaboration > Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under Policies select Anti-malware. 5. Click on the Default (Default) policy. 6. Verify that Notify an admin about undelivered messages from internal senders is set to On and that there is at least one email address under Administrator email address. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following command: Get-MalwareFilterPolicy | fl Identity, EnableInternalSenderAdminNotifications, InternalSenderAdminAddress 3. Verify that EnableInternalSenderAdminNotifications is set to True and a InternalSenderAdminAddress address is defined. Note: Audit and Remediation guidance may focus on the Default policy however, if a Custom Policy exists in the organization's tenant, then ensure the setting is set as outlined in the highest priority policy listed.", + "AdditionalInformation": "", + "DefaultValue": "EnableInternalSenderAdminNotifications : False InternalSenderAdminAddress : $null", + "References": "https://learn.microsoft.com/en-us/defender-office-365/anti-malware-protection-about:https://learn.microsoft.com/en-us/defender-office-365/anti-malware-policies-configure" + } + ] + }, + { + "Id": "2.1.4", + "Description": "The Safe Attachments policy helps protect users from malware in email attachments by scanning attachments for viruses, malware, and other malicious content. When an email attachment is received by a user, Safe Attachments will scan the attachment in a secure environment and provide a verdict on whether the attachment is safe or not.", + "Checks": [ + "defender_safe_attachments_policy_enabled" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E5 Level 2", + "AssessmentStatus": "Automated", + "Description": "The Safe Attachments policy helps protect users from malware in email attachments by scanning attachments for viruses, malware, and other malicious content. When an email attachment is received by a user, Safe Attachments will scan the attachment in a secure environment and provide a verdict on whether the attachment is safe or not.", + "RationaleStatement": "Enabling Safe Attachments policy helps protect against malware threats in email attachments by analyzing suspicious attachments in a secure, cloud-based environment before they are delivered to the user's inbox. This provides an additional layer of security and can prevent new or unseen types of malware from infiltrating the organization's network.", + "ImpactStatement": "Delivery of email with attachments may be delayed while scanning is occurring.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand E-mail & Collaboration > Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under Policies select Safe Attachments. 5. Click + Create. 6. Create a Policy Name and Description, and then click Next. 7. Select all valid domains and click Next. 8. Select Block. 9. Quarantine policy is AdminOnlyAccessPolicy. 10. Leave Enable redirect unchecked. 11. Click Next and finally Submit. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. To change an existing policy modify the example below and run the following PowerShell command: Set-SafeAttachmentPolicy -Identity 'Example policy' -Action 'Block' - QuarantineTag 'AdminOnlyAccessPolicy' -Enable $true 3. Or, edit and run the below example to create a new safe attachments policy. New-SafeAttachmentPolicy -Name \"CIS 2.1.4\" -Enable $true -Action 'Block' - QuarantineTag 'AdminOnlyAccessPolicy' New-SafeAttachmentRule -Name \"CIS 2.1.4 Rule\" -SafeAttachmentPolicy \"CIS 2.1.4\" -RecipientDomainIs 'exampledomain[.]com' Note: Policy targets such as users and domains should include domains, or groups that provide coverage for a majority of users in the organization. Different inclusion and exclusion use cases are not covered in the benchmark.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand E-mail & Collaboration > Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under Policies select Safe Attachments. 5. Inspect the highest priority policy. 6. Verify that Users and domains and Included recipient domains are in scope for the organization. 7. Verify that Safe Attachments detection response: is set to Block - Block current and future messages and attachments with detected malware. 8. Verify that Quarantine Policy is set to AdminOnlyAccessPolicy. 9. Verify that the policy is not disabled. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-SafeAttachmentPolicy | ft Identity,Enable,Action,QuarantineTag 3. Inspect the highest priority safe attachments policy and ensure the properties and values match the below: Enable : True Action : Block QuarantineTag : AdminOnlyAccessPolicy Note: To view the priority for a policy the Get-SafeAttachmentRule must be used. Built-in policies will always have a priority of lowest while presets like strict and standard can be viewed with Get-ATPProtectionPolicyRule. Strict and standard presets always operate at a higher priority than custom policies.", + "AdditionalInformation": "", + "DefaultValue": "Identity : Built-In Protection Policy Enable : True Action : Block QuarantineTag : AdminOnlyAccessPolicy Priority : (lowest)", + "References": "https://learn.microsoft.com/en-us/defender-office-365/safe-attachments-about:https://learn.microsoft.com/en-us/defender-office-365/safe-attachments-policies-configure" + } + ] + }, + { + "Id": "2.1.5", + "Description": "Safe Attachments for SharePoint, OneDrive, and Microsoft Teams scans these services for malicious files.", + "Checks": [ + "defender_atp_safe_attachments_and_docs_configured" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E5 Level 2", + "AssessmentStatus": "Automated", + "Description": "Safe Attachments for SharePoint, OneDrive, and Microsoft Teams scans these services for malicious files.", + "RationaleStatement": "Safe Attachments for SharePoint, OneDrive, and Microsoft Teams protect organizations from inadvertently sharing malicious files. When a malicious file is detected that file is blocked so that no one can open, copy, move, or share it until further actions are taken by the organization's security team.", + "ImpactStatement": "Impact associated with Safe Attachments is minimal, and equivalent to impact associated with anti-virus scanners in an environment.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com 2. Under Email & collaboration select Policies & rules 3. Select Threat policies then Safe Attachments. 4. Click on Global settings 5. Click to Enable Turn on Defender for Office 365 for SharePoint, OneDrive, and Microsoft Teams 6. Click to Enable Turn on Safe Documents for Office clients 7. Click to Disable Allow people to click through Protected View even if Safe Documents identified the file as malicious. 8. Click Save To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-AtpPolicyForO365 -EnableATPForSPOTeamsODB $true -EnableSafeDocs $true - AllowSafeDocsOpen $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Under Email & collaboration select Policies & rules. 3. Select Threat policies then Safe Attachments. 4. Click on Global settings. 5. Verify that the Turn on Defender for Office 365 for SharePoint, OneDrive, and Microsoft Teams toggle is set to Enabled. 6. Verify that the Turn on Safe Documents for Office clients toggle is set to Enabled. 7. Verify that the Allow people to click through Protected View even if Safe Documents identified the file as malicious toggle is set to Disabled. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-AtpPolicyForO365 | fl Name,EnableATPForSPOTeamsODB,EnableSafeDocs,AllowSafeDocsOpen Verify the values for each parameter as below: EnableATPForSPOTeamsODB : True EnableSafeDocs : True AllowSafeDocsOpen : False", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/defender-office-365/safe-attachments-for-spo-odfb-teams-about" + } + ] + }, + { + "Id": "2.1.6", + "Description": "In Microsoft 365 organizations with mailboxes in Exchange Online or standalone Exchange Online Protection (EOP) organizations without Exchange Online mailboxes, email messages are automatically protected against spam (junk email) by EOP. Configure Exchange Online Spam Policies to copy emails and notify someone when a sender in the organization has been blocked for sending spam emails.", + "Checks": [ + "defender_antispam_outbound_policy_configured" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "In Microsoft 365 organizations with mailboxes in Exchange Online or standalone Exchange Online Protection (EOP) organizations without Exchange Online mailboxes, email messages are automatically protected against spam (junk email) by EOP. Configure Exchange Online Spam Policies to copy emails and notify someone when a sender in the organization has been blocked for sending spam emails.", + "RationaleStatement": "A blocked account is a good indication that the account in question has been breached, and an attacker is using it to send spam emails to other people.", + "ImpactStatement": "Notification of users that have been blocked should not cause an impact to the user.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules> Threat policies. 3. Under Policies select Anti-spam. 4. Click on the Anti-spam outbound policy (default). 5. Select Edit protection settings then under Notifications: 6. Check Send a copy of suspicious outbound messages or message that exceed these limits to these users and groups then enter the desired email addresses. 7. Check Notify these users and groups if a sender is blocked due to sending outbound spam then enter the desired email addresses. 8. Click Save. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: $BccEmailAddress = @(\"\") $NotifyEmailAddress = @(\"\") Set-HostedOutboundSpamFilterPolicy -Identity Default - BccSuspiciousOutboundAdditionalRecipients $BccEmailAddress - BccSuspiciousOutboundMail $true -NotifyOutboundSpam $true - NotifyOutboundSpamRecipients $NotifyEmailAddress Note: Audit and Remediation guidance may focus on the Default policy however, if a Custom Policy exists in the organization's tenant, then ensure the setting is set as outlined in the highest priority policy listed.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules > Threat policies. 3. Under Policies select Anti-spam. 4. Click on the Anti-spam outbound policy (default). 5. Verify that Send a copy of suspicious outbound messages or message that exceed these limits to these users and groups is set to On, ensure the email address is correct. 6. Verify that Notify these users and groups if a sender is blocked due to sending outbound spam is set to On, ensure the email address is correct. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-HostedOutboundSpamFilterPolicy | Select-Object Bcc*, Notify* 3. Verify both BccSuspiciousOutboundMail and NotifyOutboundSpam are set to True and the email addresses to be notified are correct. Note: Audit and Remediation guidance may focus on the Default policy however, if a Custom Policy exists in the organization's tenant, then ensure the setting is set as outlined in the highest priority policy listed.", + "AdditionalInformation": "", + "DefaultValue": "BccSuspiciousOutboundAdditionalRecipients : {} BccSuspiciousOutboundMail : False NotifyOutboundSpamRecipients : {} NotifyOutboundSpam : False", + "References": "https://learn.microsoft.com/en-us/defender-office-365/outbound-spam-protection-about" + } + ] + }, + { + "Id": "2.1.7", + "Description": "By default, Office 365 includes built-in features that help protect users from phishing attacks. Set up anti-phishing polices to increase this protection, for example by refining settings to better detect and prevent impersonation and spoofing attacks. The default policy applies to all users within the organization and is a single view to fine-tune anti- phishing protection. Custom policies can be created and configured for specific users, groups or domains within the organization and will take precedence over the default policy for the scoped users.", + "Checks": [ + "defender_antiphishing_policy_configured" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E5 Level 2", + "AssessmentStatus": "Automated", + "Description": "By default, Office 365 includes built-in features that help protect users from phishing attacks. Set up anti-phishing polices to increase this protection, for example by refining settings to better detect and prevent impersonation and spoofing attacks. The default policy applies to all users within the organization and is a single view to fine-tune anti- phishing protection. Custom policies can be created and configured for specific users, groups or domains within the organization and will take precedence over the default policy for the scoped users.", + "RationaleStatement": "Protects users from phishing attacks (like impersonation and spoofing) and uses safety tips to warn users about potentially harmful messages.", + "ImpactStatement": "Mailboxes that are used for support systems such as helpdesk and billing systems send mail to internal users and are often not suitable candidates for impersonation protection. Care should be taken to ensure that these systems are excluded from Impersonation Protection.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules 3. Select Threat policies. 4. Under Policies select Anti-phishing and click Create. 5. Name the policy, continuing and clicking Next as needed: o Add Groups and/or Domains that contain a majority of the organization. o Set Phishing email threshold to 3 - More Aggressive o Check Enable users to protect and add up to 350 users o Check Enable domains to protect and check Include domains I own o Check Enable mailbox intelligence (Recommended) o Check Enable Intelligence for impersonation protection (Recommended) o Check Enable spoof intelligence (Recommended) 6. Under Actions configure the following: o Set If a message is detected as user impersonation to Quarantine the message o Set If a message is detected as domain impersonation to Quarantine the message o Set If Mailbox Intelligence detects an impersonated user to Quarantine the message o Leave Honor DMARC record policy when the message is detected as spoof checked. o Check Show first contact safety tip (Recommended) o Check Show user impersonation safety tip o Check Show domain impersonation safety tip o Check Show user impersonation unusual characters safety tip 7. Finally, click Next and Submit the policy. Note: DefaultFullAccessWithNotificationPolicy is suggested but not required. Users will be notified that impersonation emails are in the Quarantine. To remediate using PowerShell: 1. Connect to Exchange Online service using Connect-ExchangeOnline. 2. Run the following Exchange Online PowerShell script to create an AntiPhish policy: # Create the Policy $params = @{ Name = \"CIS AntiPhish Policy\" PhishThresholdLevel = 3 EnableTargetedUserProtection = $true EnableOrganizationDomainsProtection = $true EnableMailboxIntelligence = $true EnableMailboxIntelligenceProtection = $true EnableSpoofIntelligence = $true TargetedUserProtectionAction = 'Quarantine' TargetedDomainProtectionAction = 'Quarantine' MailboxIntelligenceProtectionAction = 'Quarantine' TargetedUserQuarantineTag = 'DefaultFullAccessWithNotificationPolicy' MailboxIntelligenceQuarantineTag = 'DefaultFullAccessWithNotificationPolicy' TargetedDomainQuarantineTag = 'DefaultFullAccessWithNotificationPolicy' EnableFirstContactSafetyTips = $true EnableSimilarUsersSafetyTips = $true EnableSimilarDomainsSafetyTips = $true EnableUnusualCharactersSafetyTips = $true HonorDmarcPolicy = $true } New-AntiPhishPolicy @params # Create the rule for all users in all valid domains and associate with Policy New-AntiPhishRule -Name $params.Name -AntiPhishPolicy $params.Name - RecipientDomainIs (Get-AcceptedDomain).Name -Priority 0 3. The new policy can be edited in the UI or via PowerShell. Note: Remediation guidance is intended to help create a qualifying AntiPhish policy that meets the recommended criteria while protecting the majority of the organization. It's understood some individual user exceptions may exist or exceptions for the entire policy if another product acts as a similar control.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules 3. Select Threat policies. 4. Under Policies select Anti-phishing. 5. Verify an AntiPhish policy exists that is On and meets the following criteria: 6. Under Users, groups, and domains o Verify that the included domains and groups includes a majority of the organization. 7. Under Phishing threshold & protection verify the following: o Phishing email threshold is at least 3 - More Aggressive. o User impersonation protection is On and contains a subset of users. o Domain impersonation protection is On for owned domains. o Mailbox intelligence and Mailbox intelligence for impersonations and Spoof intelligence are On. 8. Under Actions verify the following: o If a message is detected as user impersonation is set to Quarantine the message. o If a message is detected as domain impersonation is set to Quarantine the message. o If Mailbox Intelligence detects an impersonated user is set to Quarantine the message. o First contact safety tip is On. o User impersonation safety tip is On. o Domain impersonation safety tip is On. o Unusual characters safety tip is On. o Honor DMARC record policy when the message is detected as spoof is On. Note: DefaultFullAccessWithNotificationPolicy is suggested but not required. Users will be notified that impersonation emails are in the Quarantine. To audit using PowerShell: 1. Connect to Exchange Online service using Connect-ExchangeOnline. 2. Run the following Exchange Online PowerShell commands: $params = @( \"name\",\"Enabled\",\"PhishThresholdLevel\",\"EnableTargetedUserProtection\" \"EnableOrganizationDomainsProtection\",\"EnableMailboxIntelligence\" \"EnableMailboxIntelligenceProtection\",\"EnableSpoofIntelligence\" \"TargetedUserProtectionAction\",\"TargetedDomainProtectionAction\" \"MailboxIntelligenceProtectionAction\",\"EnableFirstContactSafetyTips\" \"EnableSimilarUsersSafetyTips\",\"EnableSimilarDomainsSafetyTips\" \"EnableUnusualCharactersSafetyTips\",\"TargetedUsersToProtect\" \"HonorDmarcPolicy\" ) Get-AntiPhishPolicy | fl $params 3. Verify there is a policy created that has matching values for the following parameters: Enabled : True PhishThresholdLevel : 3 EnableTargetedUserProtection : True EnableOrganizationDomainsProtection : True EnableMailboxIntelligence : True EnableMailboxIntelligenceProtection : True EnableSpoofIntelligence : True TargetedUserProtectionAction : Quarantine TargetedDomainProtectionAction : Quarantine MailboxIntelligenceProtectionAction : Quarantine EnableFirstContactSafetyTips : True EnableSimilarUsersSafetyTips : True EnableSimilarDomainsSafetyTips : True EnableUnusualCharactersSafetyTips : True TargetedUsersToProtect : {} HonorDmarcPolicy : True 4. Verify that TargetedUsersToProtect contains a subset of the organization, up to 350 users, for targeted Impersonation Protection. 5. Use PowerShell to verify the AntiPhishRule is configured and enabled. Get-AntiPhishRule | ft AntiPhishPolicy,Priority,State,SentToMemberOf,RecipientDomainIs 6. Identity correct rule from the matching AntiPhishPolicy name in step 3. Ensure the rule defines groups or domains that include the majority of the organization by inspecting SentToMemberOf or RecipientDomainIs. Note: Audit guidance is intended to help identify a qualifying AntiPhish policy+rule that meets the recommended criteria while protecting the majority of the organization. It's understood some individual user exceptions may exist or exceptions for the entire policy if another product stands in as an equivalent control.", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/defender-office-365/anti-phishing-protection-about:https://learn.microsoft.com/en-us/defender-office-365/anti-phishing-policies-eop-configure" + } + ] + }, + { + "Id": "2.1.8", + "Description": "For each domain that is configured in Exchange, a corresponding Sender Policy Framework (SPF) record should be created.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "For each domain that is configured in Exchange, a corresponding Sender Policy Framework (SPF) record should be created.", + "RationaleStatement": "SPF records enable Exchange Online Protection and other mail systems to verify which servers are authorized to send email for a domain. This helps those systems determine whether a message is legitimate or potentially spoofed. Enforcing a -all or ~all ensures that any undocumented or unauthorized networks attempting to send on behalf of the organization are immediately rejected, reducing the risk of impersonation. If an organization does not have full visibility into where its email originates, this represents a significant security gap. For example, if an email server is sending mail from an unexpected location across the country without your knowledge, that is a serious issue. Addressing this requires a deliberate discovery process to identify all legitimate sending sources, rather than allowing unknown systems to continue sending email unchecked.", + "ImpactStatement": "Setting up SPF records typically has minimal operational impact. However, organizations must ensure proper configuration, as misconfigured SPF records can cause legitimate email to be flagged as spam or fail authentication checks. Additionally, identifying all legitimate senders outside of the default Microsoft 365 IP ranges may require extra time and coordination during the discovery phase.", + "RemediationProcedure": "To remediate using a DNS provider: For each domain identified as non-compliant during the audit, make the necessary updates in your DNS provider or through your third-party SPF management service. Missing SPF Record: If a domain does not currently have an SPF record, create one similar to the example below, assuming all email is routed through Exchange Online (Microsoft 365 or Microsoft 365 GCC): # Hard fail v=spf1 include:spf.protection.outlook.com -all # Soft fail v=spf1 include:spf.protection.outlook.com ~all Additional Senders: If other authorized email services are used, add their SPF entries using the include: mechanism as needed. For example: v=spf1 include:spf.protection.outlook.com include:exampledomain.net -all", + "AuditProcedure": "STEP 1: Determine the list of domains to audit Using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com 2. Expand Settings > Domains. 3. Take note of all custom domains and subdomains defined. o Exclude the (MOERA) domain *.onmicrosoft.com o Exclude the coexistence domain *.mail.onmicrosoft.com (if shown). 4. Use this list of domains in the audit procedure. Using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following: Get-AcceptedDomain | Where-Object {$_.IsCoExistenceDomain -eq $false -and $_.InitialDomain -eq $false} 3. Use this list in the audit procedure. Note: Microsoft owns the tenant's organizational level *.onmicrosoft.com domains, so it is not necessary to create SPF records for the initial (MOERA) or the coexistence (hybrid) domain. STEP 2: Perform the Audit using PowerShell: 1. Open a PowerShell prompt. 2. Run the following for each custom domain identified in Step 1 that sends email from Exchange Online: Resolve-DnsName domain1.com -Type TXT | fl Ensure the following criteria are met: 1. A valid TXT record must begin with v=spf1, which indicates it is SPF v1 (the current standard). 2. The record must be either directly or indirectly managed: o An indirectly managed record uses a modifier like redirect=[domain], which allows centralized SPF management by a trusted vendor. 3. The record must end with a hard or soft fail policy: o The record must end with: -all or ~all, allowing for a hard fail or soft fail. o If the SPF record uses the redirect= modifier, the redirected SPF record must terminate with a compliant qualifier (not the parent). This will require repeating the DNS lookup against the redirected domain. o Other qualifiers not listed are not compliant as an end state. Parked domains: Ensure that any domain not used for sending email has an SPF record explicitly indicating that no mail is authorized from that domain, using v=spf1 - all. Below are examples of SPF records that are compliant. The audit does not evaluate specific include domains for compliant states. v=spf1 include:spf.protection.outlook.com -all v=spf1 include:spf.protection.outlook.com ~all # GCC High or DoD example v=spf1 include:exampledomain.net include:spf.protection.office365.us ~all # 21Vianet v=spf1 include:spf.protection.partner.outlook.cn ~all # Parked domains v=spf1 -all Note: Resolve-DnsName is not available on versions of Windows earlier than Windows 8 and Windows Server 2012. Use alternatives such as nslookup when needed.", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/email-authentication-spf-configure?view=o365-worldwide:https://datatracker.ietf.org/doc/html/rfc7208:https://learn.microsoft.com/en-us/defender-office-365/email-authentication-spf-configure?view=o365-worldwide#scenario-parked-domains" + } + ] + }, + { + "Id": "2.1.9", + "Description": "DKIM is one of the trio of Authentication methods (SPF, DKIM and DMARC) that help prevent attackers from sending messages that look like they come from your domain. DKIM lets an organization add a digital signature to outbound email messages in the message header. When DKIM is configured, the organization authorizes it's domain to associate, or sign, it's name to an email message using cryptographic authentication. Email systems that get email from this domain can use a digital signature to help verify whether incoming email is legitimate. Use of DKIM in addition to SPF and DMARC to help prevent malicious actors using spoofing techniques from sending messages that look like they are coming from your domain.", + "Checks": [ + "defender_domain_dkim_enabled" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "DKIM is one of the trio of Authentication methods (SPF, DKIM and DMARC) that help prevent attackers from sending messages that look like they come from your domain. DKIM lets an organization add a digital signature to outbound email messages in the message header. When DKIM is configured, the organization authorizes it's domain to associate, or sign, it's name to an email message using cryptographic authentication. Email systems that get email from this domain can use a digital signature to help verify whether incoming email is legitimate. Use of DKIM in addition to SPF and DMARC to help prevent malicious actors using spoofing techniques from sending messages that look like they are coming from your domain.", + "RationaleStatement": "By enabling DKIM with Office 365, messages that are sent from Exchange Online will be cryptographically signed. This will allow the receiving email system to validate that the messages were generated by a server that the organization authorized and not being spoofed.", + "ImpactStatement": "There should be no impact of setting up DKIM however, organizations should ensure appropriate setup to ensure continuous mail-flow.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand Email & collaboration > Policies & rules > Threat policies. 3. Under Rules section click Email authentication settings. 4. Select DKIM 5. Select the domain to remediate. 6. Click Create DKIM keys. 7. Microsoft provides the properly formatted CNAME records, copy these for later use. 8. In another browser tab or window, go to the domain registrar for the domain, and then create the two CNAME records using the information from the previous step. 9. Return the domain properties flyout and toggle Sign messages for this domain with DKIM signatures to Enabled. If successful the status will show Signing DKIM signatures for this domain.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand Email & collaboration > Policies & rules > Threat policies. 3. Under Rules section click Email authentication settings. 4. Select DKIM. 5. For each Accepted domain that is configured to send email: o Skip any *.onmicrosoft.com domain (MOERA or Coexistence), these outbound messages are automatically signed by Microsoft. o Click on the domain name. o Confirm Sign messages for this domain with DKIM signatures is Enabled. o Ensure Status reads Signing DKIM signatures for this domain. 6. A status of Not signing DKIM signatures for this domain or No DKIM keys saved for this domain is out of compliance. Note: For step 5 these can also be audited the overview showing all domains. In this case a passing audit procedure will display the Toggle set as Enabled and Status as Valid. To audit using PowerShell: 1. Connect to Exchange Online service using Connect-ExchangeOnline. 2. Run the following: # Get-DkimSigningConfig does not display unconfigured domains, # so we first get all accepted domains and then match them against # the DKIM signing configurations. $Domains = Get-AcceptedDomain | Where-Object {$_.IsCoExistenceDomain -eq $false -and $_.InitialDomain -eq $false} $DKIMCfg = Get-DkimSigningConfig # Generate the report $Report = foreach ($Domain in $Domains) { $DKIM = $DKIMCfg | Where-Object { $_.Name -eq $Domain.Name } [PSCustomObject]@{ DomainName = $Domain.Name Enabled = [bool]$DKIM.Enabled Status = if ($DKIM) { $DKIM.Status } else { \"Not Configured\" } IsCISCompliant = ($DKIM.Enabled -and $DKIM.Status -eq \"Valid\") } } # Output the report $Report | Format-Table -AutoSize # Optionally, export the report to a CSV file # $Report | Export-Csv -Path \"2_1_9.csv\" -NoTypeInformation 3. For each domain that is configured to send email verify: o Enabled is True o Status is Valid. o Note: The property IsCISCompliant will also validate whether the state is compliant. Note: If you own registered domains that aren't used for email or anything at all (also known as parked domains), don't publish DKIM records for those domains. The lack of a DKIM record (hence, the lack of a public key in DNS to validate the message signature) prevents DKIM validation of forged domains.", + "AdditionalInformation": "", + "DefaultValue": "Custom domains: Not configured by default MOERA onmicrosoft.com domain: Outbound email is automatically signed", + "References": "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/email-authentication-dkim-configure?view=o365-worldwide" + } + ] + }, + { + "Id": "2.1.10", + "Description": "DMARC, or Domain-based Message Authentication, Reporting, and Conformance, assists recipient mail systems in determining the appropriate action to take when messages from a domain fail to meet SPF or DKIM authentication criteria.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "DMARC, or Domain-based Message Authentication, Reporting, and Conformance, assists recipient mail systems in determining the appropriate action to take when messages from a domain fail to meet SPF or DKIM authentication criteria.", + "RationaleStatement": "DMARC strengthens the trustworthiness of messages sent from an organization's domain to destination email systems. When combined with SPF (Sender Policy Framework) and DKIM (DomainKeys Identified Mail), DMARC significantly enhances defenses against email spoofing and phishing attempts. This includes the MOERA domain (e.g., contoso.onmicrosoft.com), which is provisioned with every Microsoft 365 tenant and is capable of originating email. Because it is often overlooked in favor of custom domains, it represents a common gap in email authentication coverage if left unprotected. Leaving a DMARC policy set to p=none can result in the mail system taking no action when a spear-phishing email fails DMARC but passes SPF and DKIM checks. Having DMARC fully configured is a critical part of preventing business email compromise.", + "ImpactStatement": "The remediation portion can take time to implement and involves a multi-staged approach over time. First, a baseline of the current state of email will be established with p=none and rua and ruf. Once the environment is better understood and reports have been analyzed, an organization will move to the final state with DMARC record values as outlined in the audit section.", + "RemediationProcedure": "To remediate using a DNS provider: 1. For any out of compliance domain sending email, add the following record to DNS: Record: _dmarc.domain1.com Type: TXT Value: v=DMARC1; p=none; rua=mailto:; ruf=mailto: 2. This will create a basic DMARC policy that will allow the organization to start monitoring message statistics. 3. One week is enough time for data generated by the reports to be useful in understanding email trends and traffic. The final step requires implementing a policy of p=reject OR p=quarantine and pct=100 with the necessary rua and ruf email addresses defined: Record: _dmarc.domain1.com Type: TXT Value: v=DMARC1; p=reject; pct=100; rua=mailto:; ruf=mailto: Parked Domains: For any domain not used for sending email, add the following record to DNS: Record: _dmarc.domain1.com Type: TXT Value: v=DMARC1; p=reject; To remediate the MOERA domain using the UI: 1. Navigate to the Microsoft 365 admin center https://admin.microsoft.com/ 2. Expand Settings and select Domains. 3. Select your tenant domain (for example, contoso.onmicrosoft.com). 4. Select DNS records and click + Add record. 5. Add a new record with the TXT name of _dmarc with the appropriate values outlined above.", + "AuditProcedure": "STEP 1: Determine the list of domains to audit Using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com 2. Expand Settings > Domains. 3. Take note of all custom domains and subdomains defined. o Exclude the coexistence domain *.mail.onmicrosoft.com (if shown). 4. Include the MOERA domain: [tenant].onmicrosoft.com 5. Use this list of domains in the audit procedure. Using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following: Get-AcceptedDomain | Where-Object {$_.IsCoExistenceDomain -eq $false} 3. Use this list in the audit procedure. STEP 2: Perform the Audit using PowerShell: Note: Resolve-DnsName is not available on versions of Windows earlier than Windows 8 and Windows Server 2012. Use alternatives such as nslookup when needed. 1. Open a PowerShell prompt. 2. Run the following for each domain identified in Step 1: Resolve-DnsName _dmarc.domain1.com -Type TXT 3. Ensure the following criteria are met: 1. The record must be either directly or indirectly managed: - An indirectly managed record would use a CNAME to point to another record. - If the record is managed indirectly then that record must meet all criteria. This would require an additional DNS lookup. 2. The version v tag value must be v=DMARC1, which identifies it as a DMARC record. 3. The policy p tag value is p=quarantine OR p=reject. 4. The sampling rate pct tag value is pct=100 or does not exist. (A non- existent pct tag indicates sampling is 100 percent) 5. The aggregate data rua tag value is configured, i.e. rua=mailto:. 6. The failure data ruf tag value is configured, i.e. ruf=mailto:. 4. Subdomain considerations o When subdomains of the organizational domain are in scope, DMARC policy is determined using a two-step record discovery process (RFC 7489, Section 6.6.3): 1. If the subdomain has its own valid DMARC record (i.e., a record that includes the required p= tag), only that record is used. Nothing is inherited from the organizational domain. Any tags not explicitly defined in the subdomain's record fall back to their RFC- defined default values. 2. If the subdomain does not have a valid DMARC record - either because no record exists or because the record is malformed (e.g., missing the required p= tag) - the organizational domain's record is used. When determining policy disposition, the sp= (subdomain policy) tag is applied if present; otherwise, the p= tag is used. o This fallback is record discovery, not per-tag inheritance. The lookup always falls back to the organizational domain directly. Intermediate parent subdomains are never consulted. o The sp= tag is only meaningful when set on the organizational domain's record and is ignored on subdomain records. 5. Compliance is met when each domain and subdomain meets the requirements in steps 3 and 4. Parked Domains: Ensure that any domain not used for sending email has a DMARC record explicitly indicating that no mail is authorized from that domain. 1. The version v tag value must be v=DMARC1, which identifies it as a DMARC record. 2. The policy p tag value is p=reject. The following example records would pass as they contain a policy that would either quarantine or reject messages failing DMARC, and the policy affects 100% of mail pct=100 as well as containing valid reporting and aggregate addresses: v=DMARC1; p=reject; pct=100; rua=mailto:rua@example.com; ruf=mailto:ruf@example.com; fo=1 v=DMARC1; p=reject; rua=mailto:rua@example.com; ruf=mailto:ruf@example.com; fo=1 v=DMARC1; p=quarantine; pct=100; sp=none; fo=1; ri=3600; rua=mailto:rua@example.com; ruf=mailto:ruf@example.com; # Parked domains v=DMARC1; p=reject; Note: The third example includes sp=none, which sets the subdomain policy to monitor- only. While the organizational domain itself would be compliant, any subdomains would not meet the audit criteria if they exist. Auditors must evaluate subdomains separately to confirm compliance.", + "AdditionalInformation": "Microsoft has a list of best practices for implementing DMARC that cover these steps in detail.", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/defender-office-365/email-authentication-dmarc-configure?view=o365-worldwide:https://learn.microsoft.com/en-us/defender-office-365/step-by-step-guides/how-to-enable-dmarc-reporting-for-microsoft-online-email-routing-address-moera-and-parked-domains?view=o365-worldwide:https://media.defense.gov/2024/May/02/2003455483/-1/-1/0/CSA-NORTH-KOREAN-ACTORS-EXPLOIT-WEAK-DMARC.PDF:https://www.rfc-editor.org/rfc/rfc7489" + } + ] + }, + { + "Id": "2.1.11", + "Description": "The Common Attachment Types Filter lets a user block known and custom malicious file types from being attached to emails. The policy provided by Microsoft covers 53 extensions, and an additional custom list of extensions can be defined. The list of 186 extensions provided in this recommendation is comprehensive but not exhaustive.", + "Checks": [ + "defender_malware_policy_comprehensive_attachments_filter_applied" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "The Common Attachment Types Filter lets a user block known and custom malicious file types from being attached to emails. The policy provided by Microsoft covers 53 extensions, and an additional custom list of extensions can be defined. The list of 186 extensions provided in this recommendation is comprehensive but not exhaustive.", + "RationaleStatement": "Blocking known malicious file types can help prevent malware-infested files from infecting a host or performing other malicious attacks such as phishing and data extraction. Defining a comprehensive list of attachments can help protect against additional unknown and known threats. Many legacy file formats, binary files and compressed files have been used as delivery mechanisms for malicious software. Organizations can protect themselves from Business E-mail Compromise (BEC) by allow-listing only the file types relevant to their line of business and blocking all others.", + "ImpactStatement": "For file types that are business necessary users will need to use other organizationally approved methods to transfer blocked extension types between business partners.", + "RemediationProcedure": "To Remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following script after editing InternalSenderAdminAddress: # Create an attachment policy and associated rule. The rule is # intentionally disabled allowing the org to enable it when ready $Policy = @{ Name = \"CIS L2 Attachment Policy\" EnableFileFilter = $true ZapEnabled = $true EnableInternalSenderAdminNotifications = $true InternalSenderAdminAddress = 'admin@contoso.com' # Change this. } $L2Extensions = @( \"7z\", \"a3x\", \"ace\", \"ade\", \"adp\", \"ani\", \"app\", \"appinstaller\", \"applescript\", \"application\", \"appref-ms\", \"appx\", \"appxbundle\", \"arj\", \"asd\", \"asx\", \"bas\", \"bat\", \"bgi\", \"bz2\", \"cab\", \"chm\", \"cmd\", \"com\", \"cpl\", \"crt\", \"cs\", \"csh\", \"daa\", \"dbf\", \"dcr\", \"deb\", \"desktopthemepackfile\", \"dex\", \"diagcab\", \"dif\", \"dir\", \"dll\", \"dmg\", \"doc\", \"docm\", \"dot\", \"dotm\", \"elf\", \"eml\", \"exe\", \"fxp\", \"gadget\", \"gz\", \"hlp\", \"hta\", \"htc\", \"htm\", \"html\", \"hwpx\", \"ics\", \"img\", \"inf\", \"ins\", \"iqy\", \"iso\", \"isp\", \"jar\", \"jnlp\", \"js\", \"jse\", \"kext\", \"ksh\", \"lha\", \"lib\", \"library-ms\", \"lnk\", \"lzh\", \"macho\", \"mam\", \"mda\", \"mdb\", \"mde\", \"mdt\", \"mdw\", \"mdz\", \"mht\", \"mhtml\", \"mof\", \"msc\", \"msi\", \"msix\", \"msp\", \"msrcincident\", \"mst\", \"ocx\", \"odt\", \"ops\", \"oxps\", \"pcd\", \"pif\", \"plg\", \"pot\", \"potm\", \"ppa\", \"ppam\", \"ppkg\", \"pps\", \"ppsm\", \"ppt\", \"pptm\", \"prf\", \"prg\", \"ps1\", \"ps11\", \"ps11xml\", \"ps1xml\", \"ps2\", \"ps2xml\", \"psc1\", \"psc2\", \"pub\", \"py\", \"pyc\", \"pyo\", \"pyw\", \"pyz\", \"pyzw\", \"rar\", \"reg\", \"rev\", \"rtf\", \"scf\", \"scpt\", \"scr\", \"sct\", \"searchConnector-ms\", \"service\", \"settingcontent-ms\", \"sh\", \"shb\", \"shs\", \"shtm\", \"shtml\", \"sldm\", \"slk\", \"so\", \"spl\", \"stm\", \"svg\", \"swf\", \"sys\", \"tar\", \"theme\", \"themepack\", \"timer\", \"uif\", \"url\", \"uue\", \"vb\", \"vbe\", \"vbs\", \"vhd\", \"vhdx\", \"vxd\", \"wbk\", \"website\", \"wim\", \"wiz\", \"ws\", \"wsc\", \"wsf\", \"wsh\", \"xla\", \"xlam\", \"xlc\", \"xll\", \"xlm\", \"xls\", \"xlsb\", \"xlsm\", \"xlt\", \"xltm\", \"xlw\", \"xnk\", \"xps\", \"xsl\", \"xz\", \"z\" ) # Create the policy New-MalwareFilterPolicy @Policy -FileTypes $L2Extensions # Create the rule for all accepted domains $Rule = @{ Name = $Policy.Name Enabled = $false MalwareFilterPolicy = $Policy.Name RecipientDomainIs = (Get-AcceptedDomain).Name Priority = 0 } New-MalwareFilterRule @Rule 3. When prepared enable the rule either through the UI or PowerShell. Note: Due to the number of extensions the UI method is not covered. The objects can however be edited in the UI or manually added using the list from the script. 1. Navigate to Microsoft Defender at https://security.microsoft.com/ 2. Browse to Policies & rules > Threat policies > Anti-malware.", + "AuditProcedure": "For this control, a Level 2 comprehensive attachment policy is defined as one that includes at least 120 extensions. The 184 extensions included are a known vector for malicious activity. To pass, organizations must demonstrate at least a 90% adoption rate of the extension list referenced in the script below, with allowances for justified exceptions. Since individual extensions are not assigned specific risk weights, exceptions should be based on documented business needs. Note: Utilizing the UI for auditing Anti-malware policies can be very time consuming so it is recommended to use a script like the one supplied below. To Audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following script: $AttachExts = @( '7z', 'a3x', 'ace', 'ade', 'adp', 'ani', 'apk', 'app', 'appinstaller', 'applescript', 'application', 'appref-ms', 'appx', 'appxbundle', 'arj', 'asd', 'asx', 'bas', 'bat', 'bgi', 'bz2', 'cab', 'chm', 'cmd', 'com', 'cpl', 'crt', 'cs', 'csh', 'daa', 'dbf', 'dcr', 'deb', 'desktopthemepackfile', 'dex', 'diagcab', 'dif', 'dir', 'dll', 'dmg', 'doc', 'docm', 'dot', 'dotm', 'elf', 'eml', 'exe', 'fxp', 'gadget', 'gz', 'hlp', 'hta', 'htc', 'htm', 'html', 'hwpx', 'ics', 'img', 'inf', 'ins', 'iqy', 'iso', 'isp', 'jar', 'jnlp', 'js', 'jse', 'kext', 'ksh', 'lha', 'lib', 'library', 'library-ms', 'lnk', 'lzh', 'macho', 'mam', 'mda', 'mdb', 'mde', 'mdt', 'mdw', 'mdz', 'mht', 'mhtml', 'mof', 'msc', 'msi', 'msix', 'msp', 'msrcincident', 'mst', 'ocx', 'odt', 'ops', 'oxps', 'pcd', 'pif', 'plg', 'pot', 'potm', 'ppa', 'ppam', 'ppkg', 'pps', 'ppsm', 'ppt', 'pptm', 'prf', 'prg', 'ps1', 'ps11', 'ps11xml', 'ps1xml', 'ps2', 'ps2xml', 'psc1', 'psc2', 'pub', 'py', 'pyc', 'pyo', 'pyw', 'pyz', 'pyzw', 'rar', 'reg', 'rev', 'rtf', 'scf', 'scpt', 'scr', 'sct', 'searchConnector-ms', 'service', 'settingcontent-ms', 'sh', 'shb', 'shs', 'shtm', 'shtml', 'sldm', 'slk', 'so', 'spl', 'stm', 'svg', 'swf', 'sys', 'tar', 'theme', 'themepack', 'timer', 'uif', 'url', 'uue', 'vb', 'vbe', 'vbs', 'vhd', 'vhdx', 'vxd', 'wbk', 'website', 'wim', 'wiz', 'ws', 'wsc', 'wsf', 'wsh', 'xla', 'xlam', 'xlc', 'xll', 'xlm', 'xls', 'xlsb', 'xlsm', 'xlt', 'xltm', 'xlw', 'xnk', 'xps', 'xsl', 'xz', 'z' ) $MalwareFilterPolicies = Get-MalwareFilterPolicy $MalwareFilterRules = Get-MalwareFilterRule # A policy must have at least 90% of the extensions in the reference list to pass. # This allows for some flexibility with exceptions. $PassingValue = .90 # 90% $FailThreshold = [int]($AttachExts.count * (1 - $PassingValue)) # Only evaluate policies that have more than 120 extensions defined # so we don't output failures on policies that aren't specific to # extension filtering. $CompPolicies = $MalwareFilterPolicies | Where-Object { $_.FileTypes.Count - gt 120 } if (-not $CompPolicies) { Write-Output \"## FAIL ## No comprehensive policies found to evaluate.\" return } $ExtensionReport = foreach ($policy in $CompPolicies) { $Missing = Compare-Object -ReferenceObject $AttachExts ` -DifferenceObject $policy.FileTypes ` -PassThru | Where-Object { $_.SideIndicator -eq '<=' } $FoundRule = $MalwareFilterRules | Where-Object { $_.MalwareFilterPolicy -eq $policy.Id } # Define passing conditions to determine if this policy passes all checks. $Pass = ($Missing.Count -lt $FailThreshold) -and ($FoundRule.State -eq 'Enabled') -and ($policy.EnableFileFilter -eq $true) [PSCustomObject]@{ PolicyName = $policy.Identity IsCISCompliant = $Pass EnableFileFilter = $policy.EnableFileFilter State = $FoundRule.State MissingCount = $Missing.count MissingExtensions = $Missing -join \", \" ExtensionCount = $policy.FileTypes.count } } # Output results in various formats $ExtensionReport | Format-Table -AutoSize <# Optional: Export methods $ExtensionReport | Out-GridView -Title \"Attachment Filter results\" $ExtensionReport | Export-Csv -Path \"2.1.11.csv\" -NoTypeInformation $ExtensionReport | ConvertTo-Json | Out-File -FilePath \"2.1.11.json\" #> 3. Review the results, only policies with over 120 extensions defined will be evaluated. At the end of the script examples of different output formats are given. 4. A pass is given for the following conditions: o A single active policy exists that covers all file extensions listed except those defined as an exception by the organization. o The policy has a state of Enabled. o The EnableFileFilter property is set to True. 5. The report includes a IsCISCompliant property, where True indicates in compliance, allowing for up to 10% of the listed extensions to be missing as documented exceptions. Note: Organizations should evaluate any extensions missing from the report to determine if they are valid exceptions. Note: The audit procedure intentionally does not include the action taken for matched extensions, e.g. Reject with NDR or Quarantine the message. These are considered organization specific and are not scored. When FileTypeAction is not specified the action will default to Reject the message with a non-delivery receipt (NDR). The Quarantine Policy is also considered organization specific.", + "AdditionalInformation": "", + "DefaultValue": "The following extensions are blocked by default: ace, ani, apk, app, appx, arj, bat, cab, cmd, com, deb, dex, dll, docm, elf, exe, hta, img, iso, jar, jnlp, kext, lha, lib, library, lnk, lzh, macho, msc, msi, msix, msp, mst, pif, ppa, ppam, reg, rev, scf, scr, sct, sys, uif, vb, vbe, vbs, vxd, wsc, wsf, wsh, xll, xz, z", + "References": "https://learn.microsoft.com/en-us/powershell/module/exchange/get-malwarefilterpolicy?view=exchange-ps:https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/anti-malware-policies-configure?view=o365-worldwide:https://learn.microsoft.com/en-us/office/compatibility/office-file-format-reference" + } + ], + "ConfigRequirements": [ + { + "Check": "defender_malware_policy_comprehensive_attachments_filter_applied", + "ConfigKey": "recommended_blocked_file_types", + "Operator": "superset", + "Value": [ + "ace", + "ani", + "apk", + "app", + "appx", + "arj", + "bat", + "cab", + "cmd", + "com", + "deb", + "dex", + "dll", + "docm", + "elf", + "exe", + "hta", + "img", + "iso", + "jar", + "jnlp", + "kext", + "lha", + "lib", + "library", + "lnk", + "lzh", + "macho", + "msc", + "msi", + "msix", + "msp", + "mst", + "pif", + "ppa", + "ppam", + "reg", + "rev", + "scf", + "scr", + "sct", + "sys", + "uif", + "vb", + "vbe", + "vbs", + "vxd", + "wsc", + "wsf", + "wsh", + "xll", + "xz", + "z" + ] + } + ] + }, + { + "Id": "2.1.12", + "Description": "In Microsoft 365 organizations with Exchange Online mailboxes or standalone Exchange Online Protection (EOP) organizations without Exchange Online mailboxes, connection filtering and the default connection filter policy identify good or bad source email servers by IP addresses. The key components of the default connection filter policy are IP Allow List, IP Block List and Safe list. The recommended state is IP Allow List empty or undefined.", + "Checks": [ + "defender_antispam_connection_filter_policy_empty_ip_allowlist" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "In Microsoft 365 organizations with Exchange Online mailboxes or standalone Exchange Online Protection (EOP) organizations without Exchange Online mailboxes, connection filtering and the default connection filter policy identify good or bad source email servers by IP addresses. The key components of the default connection filter policy are IP Allow List, IP Block List and Safe list. The recommended state is IP Allow List empty or undefined.", + "RationaleStatement": "Without additional verification like mail flow rules, email from sources in the IP Allow List skips spam filtering and sender authentication (SPF, DKIM, DMARC) checks. This method creates a high risk of attackers successfully delivering email to the Inbox that would otherwise be filtered. Messages that are determined to be malware or high confidence phishing are filtered.", + "ImpactStatement": "This is the default behavior. IP Allow lists may reduce false positives, however, this benefit is outweighed by the importance of a policy which scans all messages regardless of the origin. This supports the principle of zero trust.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules> Threat policies. 3. Under Policies select Anti-spam. 4. Click on the Connection filter policy (Default). 5. Click Edit connection filter policy. 6. Remove any IP entries from Always allow messages from the following IP addresses or address range:. 7. Click Save. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-HostedConnectionFilterPolicy -Identity Default -IPAllowList @{}", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules > Threat policies. 3. Under Policies select Anti-spam. 4. Click on the Connection filter policy (Default). 5. Verify that IP Allow list contains no entries. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-HostedConnectionFilterPolicy -Identity Default | fl IPAllowList 3. Verify that IPAllowList is empty or {}", + "AdditionalInformation": "", + "DefaultValue": "IPAllowList : {}", + "References": "https://learn.microsoft.com/en-us/defender-office-365/connection-filter-policies-configure:https://learn.microsoft.com/en-us/defender-office-365/create-safe-sender-lists-in-office-365#use-the-ip-allow-list:https://learn.microsoft.com/en-us/defender-office-365/how-policies-and-protections-are-combined#user-and-tenant-settings-conflict" + } + ] + }, + { + "Id": "2.1.13", + "Description": "In Microsoft 365 organizations with Exchange Online mailboxes or standalone Exchange Online Protection (EOP) organizations without Exchange Online mailboxes, connection filtering and the default connection filter policy identify good or bad source email servers by IP addresses. The key components of the default connection filter policy are IP Allow List, IP Block List and Safe list. The safe list is a pre-configured allow list that is dynamically updated by Microsoft. The recommended safe list state is: Off or False", + "Checks": [ + "defender_antispam_connection_filter_policy_safe_list_off" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "In Microsoft 365 organizations with Exchange Online mailboxes or standalone Exchange Online Protection (EOP) organizations without Exchange Online mailboxes, connection filtering and the default connection filter policy identify good or bad source email servers by IP addresses. The key components of the default connection filter policy are IP Allow List, IP Block List and Safe list. The safe list is a pre-configured allow list that is dynamically updated by Microsoft. The recommended safe list state is: Off or False", + "RationaleStatement": "Without additional verification like mail flow rules, email from sources in the IP Allow List skips spam filtering and sender authentication (SPF, DKIM, DMARC) checks. This method creates a high risk of attackers successfully delivering email to the Inbox that would otherwise be filtered. Messages that are determined to be malware or high confidence phishing are filtered. The safe list is managed dynamically by Microsoft, and administrators do not have visibility into which senders are included. Incoming messages from email servers on the safe list bypass spam filtering.", + "ImpactStatement": "This is the default behavior. IP Allow lists may reduce false positives, however, this benefit is outweighed by the importance of a policy which scans all messages regardless of the origin. This supports the principle of zero trust.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules> Threat policies. 3. Under Policies select Anti-spam. 4. Click on the Connection filter policy (Default). 5. Click Edit connection filter policy. 6. Uncheck Turn on safe list. 7. Click Save. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-HostedConnectionFilterPolicy -Identity Default -EnableSafeList $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules > Threat policies. 3. Under Policies select Anti-spam. 4. Click on the Connection filter policy (Default). 5. Verify that Safe list is Off. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-HostedConnectionFilterPolicy -Identity Default | fl EnableSafeList 3. Verify that EnableSafeList is False", + "AdditionalInformation": "", + "DefaultValue": "EnableSafeList : False", + "References": "https://learn.microsoft.com/en-us/defender-office-365/connection-filter-policies-configure:https://learn.microsoft.com/en-us/defender-office-365/create-safe-sender-lists-in-office-365#use-the-ip-allow-list:https://learn.microsoft.com/en-us/defender-office-365/how-policies-and-protections-are-combined#user-and-tenant-settings-conflict" + } + ] + }, + { + "Id": "2.1.14", + "Description": "Anti-spam protection is a feature of Exchange Online that utilizes policies to help to reduce the amount of junk email, bulk and phishing emails a mailbox receives. These policies contain lists to allow or block specific senders or domains. - The allowed senders list - The allowed domains list - The blocked senders list - The blocked domains list The recommended state is: Do not define any Allowed domains", + "Checks": [ + "defender_antispam_policy_inbound_no_allowed_domains" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Anti-spam protection is a feature of Exchange Online that utilizes policies to help to reduce the amount of junk email, bulk and phishing emails a mailbox receives. These policies contain lists to allow or block specific senders or domains. - The allowed senders list - The allowed domains list - The blocked senders list - The blocked domains list The recommended state is: Do not define any Allowed domains", + "RationaleStatement": "Messages from entries in the allowed senders list or the allowed domains list bypass most email protection (except malware and high confidence phishing) and email authentication checks (SPF, DKIM and DMARC). Entries in the allowed senders list or the allowed domains list create a high risk of attackers successfully delivering email to the Inbox that would otherwise be filtered. The risk is increased even more when allowing common domain names as these can be easily spoofed by attackers. Microsoft specifies in its documentation that allowed domains should be used for testing purposes only.", + "ImpactStatement": "This is the default behavior. Allowed domains may reduce false positives, however, this benefit is outweighed by the importance of having a policy which scans all messages regardless of the origin. As an alternative consider sender based lists. This supports the principle of zero trust.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules> Threat policies. 3. Under Policies select Anti-spam. 4. Open each out of compliance inbound anti-spam policy by clicking on it. 5. Click Edit allowed and blocked senders and domains. 6. Select Allow domains. 7. Delete each domain from the domains list. 8. Click Done > Save. 9. Repeat as needed. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-HostedContentFilterPolicy -Identity -AllowedSenderDomains @{} Or, run this to remove allowed domains from all inbound anti-spam policies: $AllowedDomains = Get-HostedContentFilterPolicy | Where-Object {$_.AllowedSenderDomains} $AllowedDomains | Set-HostedContentFilterPolicy -AllowedSenderDomains @{}", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules > Threat policies. 3. Under Policies select Anti-spam. 4. Inspect each inbound anti-spam policy 5. Verify that Allowed domains does not contain any domain names. 6. Repeat as needed for any additional inbound anti-spam policy. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-HostedContentFilterPolicy | ft Identity,AllowedSenderDomains 3. Verify that AllowedSenderDomains is undefined for each inbound policy. Note: Each inbound policy must pass for this recommendation to be considered to be in a passing state.", + "AdditionalInformation": "", + "DefaultValue": "AllowedSenderDomains : {}", + "References": "https://learn.microsoft.com/en-us/defender-office-365/anti-spam-protection-about#allow-and-block-lists-in-anti-spam-policies" + } + ] + }, + { + "Id": "2.1.15", + "Description": "The default outbound anti-spam policy in Microsoft Defender automatically applies to all users and is designed to detect and limit suspicious email-sending behavior. The policy enforces limits based on both volume and spam detection. If a user sends too many emails too quickly or if a high percentage of their messages are flagged as spam, their ability to send email can be temporarily restricted. This helps prevent abuse from compromised accounts or inadvertent spam campaigns. When these limits are exceeded, Microsoft routes the messages through a high-risk delivery pool to protect its IP reputation and notifies administrators through built-in alert policies. The recommended state is: - External: Restrict sending to external recipients (per hour) - 500 - Internal: Restrict sending to internal recipients (per hour) - 1000 - Daily: Maximum recipient limit per day - 1000 - Action: Over limit action - Restrict the user from sending mail", + "Checks": [ + "defender_antispam_outbound_policy_configured" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "The default outbound anti-spam policy in Microsoft Defender automatically applies to all users and is designed to detect and limit suspicious email-sending behavior. The policy enforces limits based on both volume and spam detection. If a user sends too many emails too quickly or if a high percentage of their messages are flagged as spam, their ability to send email can be temporarily restricted. This helps prevent abuse from compromised accounts or inadvertent spam campaigns. When these limits are exceeded, Microsoft routes the messages through a high-risk delivery pool to protect its IP reputation and notifies administrators through built-in alert policies. The recommended state is: - External: Restrict sending to external recipients (per hour) - 500 - Internal: Restrict sending to internal recipients (per hour) - 1000 - Daily: Maximum recipient limit per day - 1000 - Action: Over limit action - Restrict the user from sending mail", + "RationaleStatement": "Message limit settings help lessen the impact of a Business Email Compromise (BEC) by automatically restricting accounts that send unusually high volumes of email. This containment prevents compromised accounts from launching large-scale attacks and helps ensure the organization's email remains trusted and deliverable. Without these limits, excessive or suspicious outbound traffic could result in Microsoft blocking the organization's email, disrupting communication and damaging reputation.", + "ImpactStatement": "Enforcing message limits may result in legitimate users being temporarily blocked from sending email if their bulk messaging activity resembles spam or exceeds volume thresholds. This can disrupt business operations, delay communication, and require administrative effort to investigate and restore access. However, these adverse effects typically stem from a lack of planning around mass mailings. To avoid triggering these limits, Microsoft recommends sending bulk email through custom subdomains or third- party bulk email providers.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules> Threat policies. 3. Under Policies select Anti-spam and click to open Anti-spam outbound policy (Default). 4. Select Edit protection settings. 5. Set the following settings to the recommended values, or more restrictive values. Message limit values of 0 are not compliant, as it represents the service default o External: Set an external message limit - 500 o Internal: Set an internal message limit - 1000 o Daily: Set a daily message limit - 1000 o Action: Restriction placed on users who reach the message limit - Restrict the user from sending mail 6. Verify that Notify these users and groups if a sender is blocked due to sending outbound spam contains a monitored mailbox. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Change the example email addresses below and run the following PowerShell commands: $params = @{ RecipientLimitExternalPerHour = 500 RecipientLimitInternalPerHour = 1000 RecipientLimitPerDay = 1000 ActionWhenThresholdReached = 'BlockUser' NotifyOutboundSpamRecipients = @('admin@example.com','security@example.com') } Set-HostedOutboundSpamFilterPolicy -Identity 'Default' @params", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules > Threat policies. 3. Under Policies select Anti-spam and click to open Anti-spam outbound policy (Default). 4. Verify the following settings are configured to the recommended level or a more restrictive value. Message limit values of 0 are not compliant, as it represents the service default: o External: Restrict sending to external recipients (per hour) - 500 o Internal: Restrict sending to internal recipients (per hour) - 1000 o Daily: Maximum recipient limit per day - 1000 o Action: Over limit action - Restrict the user from sending mail 5. Verify that a monitored mailbox is configured as a recipient under Notify these users and groups if a sender is blocked due to sending outbound spam. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following: $params = @( 'RecipientLimitExternalPerHour' 'RecipientLimitInternalPerHour' 'RecipientLimitPerDay' 'ActionWhenThresholdReached' 'NotifyOutboundSpamRecipients' ) Get-HostedOutboundSpamFilterPolicy -Identity Default | fl $params 3. Verify that each of the following properties is configured as specified: o RecipientLimitExternalPerHour is 500 or less, but not 0 o RecipientLimitInternalPerHour is 1000 or less, but not 0 o RecipientLimitPerDay is 1000 or less, but not 0 o ActionWhenThresholdReached is BlockUser o NotifyOutboundSpamRecipients contains a monitored mailbox. Note: Microsoft's Recommended Strict values represent a more restrictive and also compliant configuration. These values 400, 800, and 800 align with the values above. For further details on Standard and Strict settings, refer to the references section.", + "AdditionalInformation": "", + "DefaultValue": "RecipientLimitExternalPerHour : 0 RecipientLimitInternalPerHour : 0 RecipientLimitPerDay : 0 ActionWhenThresholdReached : BlockUserForToday The value of 0 means the service defaults are being used. More information on sending limits is here: https://learn.microsoft.com/en-us/office365/servicedescriptions/exchange- online-service-description/exchange-online-limits#sending-limits-1", + "References": "https://learn.microsoft.com/en-us/defender-office-365/outbound-spam-protection-about:https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365#outbound-spam-policy-settings:https://learn.microsoft.com/en-us/office365/servicedescriptions/exchange-online-service-description/exchange-online-limits#sending-limits-1" + } + ] + }, + { + "Id": "2.2.1", + "Description": "Organizations should monitor sign-in and audit log activity from the emergency accounts and trigger notifications to other administrators. When you monitor the activity for emergency access accounts, you can verify these accounts are only used for testing or actual emergencies. You can use Azure Monitor, Microsoft Sentinel, Defender for Cloud Apps or other tools to monitor the sign-in logs and trigger email and SMS alerts to your administrators whenever emergency access accounts sign in. This recommendation uses Defender for Cloud Apps Policies to alert on emergency access account activity. The recommended state is to monitor Activity type Log on on break-glass or emergency access accounts.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.2 Cloud apps", + "Profile": "E5 Level 1", + "AssessmentStatus": "Manual", + "Description": "Organizations should monitor sign-in and audit log activity from the emergency accounts and trigger notifications to other administrators. When you monitor the activity for emergency access accounts, you can verify these accounts are only used for testing or actual emergencies. You can use Azure Monitor, Microsoft Sentinel, Defender for Cloud Apps or other tools to monitor the sign-in logs and trigger email and SMS alerts to your administrators whenever emergency access accounts sign in. This recommendation uses Defender for Cloud Apps Policies to alert on emergency access account activity. The recommended state is to monitor Activity type Log on on break-glass or emergency access accounts.", + "RationaleStatement": "Emergency access accounts should be used in very few scenarios, for example, the last Global Administrator has left the organization and the account is inaccessible. All activity on an emergency access account should be reviewed at the time of the event to ensure the sign on is legitimate and authorized.", + "ImpactStatement": "There is no real world impact to monitoring these accounts beyond allocating staff. The frequency of emergency account sign on should be so low that any activity raises a red flag that is treated with the highest priority.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com 2. Under the Cloud Apps section select Policies -> Policy management. 3. Click on All policies and then Create policy -> Activity policy. 4. Give the policy a name and set the following: o Policy severity to High severity. o Category to Privileged accounts. o Act on Single activity. o Click Select a filter -> Activity type equals Log on. o Click Add a filter -> User Name equals as Any role. o Ensure all emergency access accounts are added to this policy or another. o Select an alert method such as Send alert as email. Note: Multiple accounts can be monitored by a single policy or by separate policies.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com 2. Under the Cloud Apps section select Policies -> Policy management. 3. Locate a privileged accounts policy that meets the following criteria o Policy severity is High severity. o Category is Privileged accounts. o Act on Single activity is selected. o Under Activities matching all of the following verify: o Filter1: Activity type equals Log on o Filter2: User Name equals as Any role o Verify all additional emergency access accounts are accounted for. o Under Alerts, verify alerting is configured. 4. Repeat this process for any additional emergency access or break-glass accounts in the organization. If matching policies do not exist, then the audit procedure is considered a fail. Note: Multiple accounts can be monitored by a single policy or by separate policies. Note: Emergency access account activity can be monitored in various ways. The audit procedure passes as long as all emergency access account activity is monitored.", + "AdditionalInformation": "", + "DefaultValue": "A policy to monitor emergency access accounts does not exist by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-emergency-access#monitor-sign-in-and-audit-logs:https://learn.microsoft.com/en-us/defender-cloud-apps/control-cloud-apps-with-policies" + } + ] + }, + { + "Id": "2.4.1", + "Description": "Identify priority accounts to utilize Microsoft 365's advanced custom security features. This is an essential tool to bolster protection for users who are frequently targeted due to their critical positions, such as executives, leaders, managers, or others who have access to sensitive, confidential, financial, or high-priority information. Once these accounts are identified, several services and features can be enabled, including threat policies, enhanced sign-in protection through conditional access policies, and alert policies, enabling faster response times for incident response teams.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.4 System", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Identify priority accounts to utilize Microsoft 365's advanced custom security features. This is an essential tool to bolster protection for users who are frequently targeted due to their critical positions, such as executives, leaders, managers, or others who have access to sensitive, confidential, financial, or high-priority information. Once these accounts are identified, several services and features can be enabled, including threat policies, enhanced sign-in protection through conditional access policies, and alert policies, enabling faster response times for incident response teams.", + "RationaleStatement": "Enabling priority account protection for users in Microsoft 365 is necessary to enhance security for accounts with access to sensitive data and high privileges, such as CEOs, CISOs, CFOs, and IT admins. These priority accounts are often targeted by spear phishing or whaling attacks and require stronger protection to prevent account compromise. To address this, Microsoft 365 and Microsoft Defender for Office 365 offer several key features that provide extra security, including the identification of incidents and alerts involving priority accounts and the use of built-in custom protections designed specifically for them.", + "ImpactStatement": "", + "RemediationProcedure": "To remediate using the UI: Remediate with a 3-step process Step 1: Enable Priority account protection in Microsoft 365 Defender: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Select System > Settings near the bottom of the left most panel. 3. Select E-mail & Collaboration > Priority account protection 4. Ensure Priority account protection is set to On Step 2: Tag priority accounts: 5. Select User tags 6. Select the PRIORITY ACCOUNT tag and click Edit 7. Select Add members to add users, or groups. Groups are recommended. 8. Repeat the previous 2 steps for any additional tags needed, such as Finance or HR. 9. Next and Submit. Step 3: Configure E-mail alerts for Priority Accounts: 10. Expand E-mail & Collaboration on the left column. 11. Select Policies & rules > Alert policy 12. Select New Alert Policy 13. Enter a valid policy Name & Description. Set Severity to High and Category to Threat management. 14. Set Activity is to Detected malware in an e-mail message 15. Mail direction is Inbound 16. Select Add Condition and User: recipient tags are 17. In the Selection option field add chosen priority tags such as Priority account. 18. Select Every time an activity matches the rule. 19. Next and verify valid recipient(s) are selected. 20. Next and select Yes, turn it on right away. Click Submit to save the alert. 21. Repeat steps 12 - 18 to create a 2nd alert for the Activity field Activity is: Phishing email detected at time of delivery Note: Any additional activity types may be added as needed. Above are the minimum recommended.", + "AuditProcedure": "To audit using the UI: Audit with a 3-step process Step 1: Verify Priority account protection is enabled: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Select System > Settings near the bottom of the left most panel. 3. Select E-mail & collaboration > Priority account protection 4. Verify that Priority account protection is set to On Step 2: Verify that priority accounts are identified and tagged accordingly: 5. Select User tags 6. Select the PRIORITY ACCOUNT tag and click Edit 7. Verify the assigned members match the organization's defined priority accounts or groups. 8. Repeat the previous 2 steps for any additional tags identified, such as Finance or HR. Step 3: Ensure alerts are configured: 9. Expand E-mail & Collaboration on the left column. 10. Select Policies & rules > Alert policy 11. Verify that at least two alert policies are configured to monitor priority accounts for the activities Detected malware in an email message and Phishing email detected at time of delivery. These alerts should meet the following criteria: o Severity: High o Category: Threat management o Mail Direction: Inbound o Recipient Tags: Includes Priority account To audit using PowerShell: 1. Connect to Exchange using Connect-ExchangeOnline 2. Retrieve the Priority Account protection state: Get-EmailTenantSettings | select EnablePriorityAccountProtection - Ensure EnablePriorityAccountProtection is true. 3. Connect to Security & Compliance PowerShell using Connect-IPPSSession 4. Retrieve alert policies targeting priority accounts: Get-ProtectionAlert | Where-Object { $_.RecipientTags -Match 'Priority account' } 5. For each returned policy, verify all of the following criteria: o Severity is High o Filter matches the pattern Mail.Direction -eq 'Inbound' o RecipientTags matches the pattern Priority account o NotificationEnabled is true o NotifyUser contains a valid email recipient o Disabled is false 6. The control is compliant when the results include: o One passing phishing policy (ThreatType = Phish) o One passing malware policy (ThreatType = Malware). o EnablePriorityAccountProtection is true from step 2.", + "AdditionalInformation": "", + "DefaultValue": "By default, priority accounts are undefined.", + "References": "https://learn.microsoft.com/en-us/microsoft-365/admin/setup/priority-accounts:https://learn.microsoft.com/en-us/defender-office-365/priority-accounts-security-recommendations" + } + ] + }, + { + "Id": "2.4.2", + "Description": "Preset security policies have been established by Microsoft, utilizing observations and experiences within datacenters to strike a balance between the exclusion of malicious content from users and limiting unwarranted disruptions. These policies can apply to all, or select users and encompass recommendations for addressing spam, malware, and phishing threats. The policy parameters are pre-determined and non-adjustable. Strict protection has the most aggressive protection of the 3 presets. - EOP: Anti-spam, Anti-malware and Anti-phishing - Defender: Spoof protection, Impersonation protection and Advanced phishing - Defender: Safe Links and Safe Attachments NOTE: The preset security polices cannot target Priority account TAGS currently, groups should be used instead.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.4 System", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Preset security policies have been established by Microsoft, utilizing observations and experiences within datacenters to strike a balance between the exclusion of malicious content from users and limiting unwarranted disruptions. These policies can apply to all, or select users and encompass recommendations for addressing spam, malware, and phishing threats. The policy parameters are pre-determined and non-adjustable. Strict protection has the most aggressive protection of the 3 presets. - EOP: Anti-spam, Anti-malware and Anti-phishing - Defender: Spoof protection, Impersonation protection and Advanced phishing - Defender: Safe Links and Safe Attachments NOTE: The preset security polices cannot target Priority account TAGS currently, groups should be used instead.", + "RationaleStatement": "Enabling priority account protection for users in Microsoft 365 is necessary to enhance security for accounts with access to sensitive data and high privileges, such as CEOs, CISOs, CFOs, and IT admins. These priority accounts are often targeted by spear phishing or whaling attacks and require stronger protection to prevent account compromise. The implementation of stringent, pre-defined policies may result in instances of false positive, however, the benefit of requiring the end-user to preview junk email before accessing their inbox outweighs the potential risk of mistakenly perceiving a malicious email as safe due to its placement in the inbox.", + "ImpactStatement": "Strict policies are more likely to cause false positives in anti-spam, phishing, impersonation, spoofing and intelligence responses.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand E-mail & collaboration > Policies & rules > Threat policies. 3. Select Preset security policies. 4. Click to Manage protection settings for Strict protection preset. 5. For Apply Exchange Online Protection select at minimum Specific recipients and include the Accounts/Groups identified as Priority Accounts. 6. For Apply Defender for Office 365 Protection select at minimum Specific recipients and include the Accounts/Groups identified as Priority Accounts. 7. For Impersonation protection click Next and add valid e-mails or priority accounts both internal and external that may be subject to impersonation. 8. For Protected custom domains add the organization's domain name, along side other key partners. 9. Click Next and finally Confirm", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand E-mail & collaboration > Policies & rules > Threat policies. 3. From here visit each section in turn: Anti-phishing Anti-spam Anti-malware Safe Attachments Safe Links 4. Verify that each contains a policy named Strict Preset Security Policy that includes the organization's priority accounts and groups. To audit using PowerShell: 1. Connect to Exchange using Connect-ExchangeOnline 2. Retrieve the ATP strict presets rule for Defender: Get-ATPProtectionPolicyRule | Where-Object { $_.Identity -eq 'Strict Preset Security Policy' } 3. Retrieve the EOP strict preset rule for Defender: Get-EOPProtectionPolicyRule | Where-Object { $_.Identity -eq 'Strict Preset Security Policy' } 4. Verify the following criteria for both the EOP Rule and ATP Rule: o State is Enabled o At least one recipient target is populated with a VIP, i.e.: - SentTo, SentToMemberOf or RecipientDomainIs is not null 5. The control is compliant when both the ATP rule and the EOP rule exist and pass the criteria in step 4.", + "AdditionalInformation": "", + "DefaultValue": "By default, presets are not applied to any users or groups.", + "References": "https://learn.microsoft.com/en-us/defender-office-365/preset-security-policies?view=o365-worldwide:https://learn.microsoft.com/en-us/defender-office-365/priority-accounts-security-recommendations:https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365?view=o365-worldwide#impersonation-settings-in-anti-phishing-policies-in-microsoft-defender-for-office-365" + } + ] + }, + { + "Id": "2.4.3", + "Description": "Microsoft Defender for Cloud Apps is a Cloud Access Security Broker (CASB). It provides visibility into suspicious activity in Microsoft 365, enabling investigation into potential security issues and facilitating the implementation of remediation measures if necessary. Some risk detection methods provided by Entra Identity Protection also require Microsoft Defender for Cloud Apps: - Suspicious manipulation of inbox rules - Suspicious inbox forwarding - New country detection - Impossible travel detection - Activity from anonymous IP addresses - Mass access to sensitive files", + "Checks": [], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.4 System", + "Profile": "E5 Level 2", + "AssessmentStatus": "Manual", + "Description": "Microsoft Defender for Cloud Apps is a Cloud Access Security Broker (CASB). It provides visibility into suspicious activity in Microsoft 365, enabling investigation into potential security issues and facilitating the implementation of remediation measures if necessary. Some risk detection methods provided by Entra Identity Protection also require Microsoft Defender for Cloud Apps: - Suspicious manipulation of inbox rules - Suspicious inbox forwarding - New country detection - Impossible travel detection - Activity from anonymous IP addresses - Mass access to sensitive files", + "RationaleStatement": "Security teams can receive notifications of triggered alerts for atypical or suspicious activities, see how the organization's data in Microsoft 365 is accessed and used, suspend user accounts exhibiting suspicious activity, and require users to log back in to Microsoft 365 apps after an alert has been triggered.", + "ImpactStatement": "", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand System > Settings > Cloud apps. 3. Scroll to Information Protection and select Files. 4. Check Enable file monitoring. 5. Scroll up to Cloud Discovery and select Microsoft Defender for Endpoint. 6. Check Enforce app access, configure a Notification URL and Save. Note: Defender for Endpoint requires a Defender for Endpoint license. Configure App Connectors: 1. Scroll to Connected apps and select App connectors. 2. Click on Connect an app and select Microsoft 365. 3. Check all Azure and Office 365 boxes then click Connect Office 365. 4. Repeat for the Microsoft Azure application.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand System > Settings > Cloud apps. 3. Scroll to Connected apps and select App connectors. 4. Verify that Microsoft 365 and Microsoft Azure both show in the list as Connected. 5. Go to Cloud Discovery > Microsoft Defender for Endpoint and verify that the integration is enabled. 6. Go to Information Protection > Files and verify Enable file monitoring is checked.", + "AdditionalInformation": "Additional Microsoft 365 Defender features include: - The option to use Defender for cloud apps as a reverse proxy, allowing for the application of access or session controls through the definition of a conditional access policy. - The purchase and implementation of the \"App Governance\" add-on, which provides more precise control over OAuth app permissions and includes additional built-in policies. A list of Defender for Cloud Apps built-in policies for Office 365 can be found at https://learn.microsoft.com/en-us/defender-cloud-apps/protect-office-365.", + "DefaultValue": "Disabled", + "References": "https://learn.microsoft.com/en-us/defender-cloud-apps/protect-office-365#connect-microsoft-365-to-microsoft-defender-for-cloud-apps:https://learn.microsoft.com/en-us/defender-cloud-apps/protect-azure#connect-azure-to-microsoft-defender-for-cloud-apps:https://learn.microsoft.com/en-us/defender-cloud-apps/best-practices:https://learn.microsoft.com/en-us/defender-cloud-apps/get-started:https://learn.microsoft.com/en-us/entra/id-protection/concept-identity-protection-risks" + } + ] + }, + { + "Id": "2.4.4", + "Description": "Zero-hour auto purge (ZAP) is a protection feature that retroactively detects and neutralizes malware and high confidence phishing. When ZAP for Teams protection blocks a message, the message is blocked for everyone in the chat. The initial block happens right after delivery, but ZAP occurs up to 48 hours after delivery.", + "Checks": [ + "defender_zap_for_teams_enabled" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.4 System", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Zero-hour auto purge (ZAP) is a protection feature that retroactively detects and neutralizes malware and high confidence phishing. When ZAP for Teams protection blocks a message, the message is blocked for everyone in the chat. The initial block happens right after delivery, but ZAP occurs up to 48 hours after delivery.", + "RationaleStatement": "ZAP is intended to protect users that have received zero-day malware messages or content that is weaponized after being delivered to users. It does this by continually monitoring spam and malware signatures taking automated retroactive action on messages that have already been delivered.", + "ImpactStatement": "As with any anti-malware or anti-phishing product, false positives may occur.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand System > Settings > Email & collaboration > Microsoft Teams protection. 3. Set Zero-hour auto purge (ZAP) to On (Default) To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following cmdlet: Set-TeamsProtectionPolicy -Identity \"Teams Protection Policy\" -ZapEnabled $true", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand System > Settings > Email & collaboration > Microsoft Teams protection. 3. Verify that Zero-hour auto purge (ZAP) is set to On (Default) 4. Under Exclude these participants review the list of exclusions and verify they are justified and within tolerance for the organization. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following cmdlets: Get-TeamsProtectionPolicy | fl ZapEnabled Get-TeamsProtectionPolicyRule | fl ExceptIf* 3. Verify that ZapEnabled is True. 4. Review the list of exclusions and ensure they are justified and within tolerance for the organization. If nothing returns from the 2nd cmdlet then there are no exclusions defined.", + "AdditionalInformation": "", + "DefaultValue": "On (Default)", + "References": "https://learn.microsoft.com/en-us/defender-office-365/zero-hour-auto-purge?view=o365-worldwide#zero-hour-auto-purge-zap-in-microsoft-teams:https://learn.microsoft.com/en-us/defender-office-365/mdo-support-teams-about?view=o365-worldwide#configure-zap-for-teams-protection-in-defender-for-office-365-plan-2" + } + ] + }, + { + "Id": "2.4.5", + "Description": "Automated Investigation and Response (AIR) investigates alerts and correlates related signals into investigations. When AIR identifies malicious email messages, it groups them into clusters based on shared characteristics like common malicious file, URL, or sending attributes and produces remediation actions for each cluster. This setting controls whether AIR-identified malicious message clusters are remediated automatically, without requiring SecOps approval. Administrators can enable automatic remediation for one or more cluster types: - Similar files: Clusters sharing a similar malicious file - Similar URLs: Clusters sharing a similar malicious URL - Multiple similar attributes: Clusters grouped by multiple shared attributes such as sender IP address, sender domain, or message subject When enabled, the configured remediation action is applied immediately upon cluster identification. The recommended state is to automatically remediate message clusters.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.4 System", + "Profile": "E5 Level 1", + "AssessmentStatus": "Manual", + "Description": "Automated Investigation and Response (AIR) investigates alerts and correlates related signals into investigations. When AIR identifies malicious email messages, it groups them into clusters based on shared characteristics like common malicious file, URL, or sending attributes and produces remediation actions for each cluster. This setting controls whether AIR-identified malicious message clusters are remediated automatically, without requiring SecOps approval. Administrators can enable automatic remediation for one or more cluster types: - Similar files: Clusters sharing a similar malicious file - Similar URLs: Clusters sharing a similar malicious URL - Multiple similar attributes: Clusters grouped by multiple shared attributes such as sender IP address, sender domain, or message subject When enabled, the configured remediation action is applied immediately upon cluster identification. The recommended state is to automatically remediate message clusters.", + "RationaleStatement": "When automatic remediation is disabled, malicious message clusters identified by AIR remain in users' mailboxes as pending actions until a security analyst manually approves each remediation. During this approval window, users may interact with, open, or forward malicious messages, increasing the risk of a successful compromise. Enabling automatic remediation ensures identified threats are contained immediately upon detection, minimizing the exposure window and reducing the operational burden on security teams to manually review and approve routine threat clusters.", + "ImpactStatement": "Automatic remediation removes the manual SecOps approval step for qualifying message cluster actions. If AIR incorrectly classifies a legitimate message cluster as malicious, affected messages will be soft deleted without prior review. Soft deleted messages are moved to the Recoverable Items folder and can be restored through the Action center, Threat Explorer, or Advanced Hunting, subject to each mailbox's deleted item retention period (14 days by default). Organizations should verify retention policies and legal obligations before enabling this setting, as retention configuration affects whether soft deleted messages remain recoverable. Clusters exceeding 10,000 messages are always excluded from automatic remediation and will continue to require manual approval. License Requirement: This setting requires Defender for Office 365 Plan 2 which is included in Microsoft 365 E5.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand System > Settings > Email & collaboration > MDO automation settings. 3. Under Message clusters check the following: o Similar files o Similar URLs o Multiple similar attributes 4. Click Save to apply the changes.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand System > Settings > Email & collaboration > MDO automation settings. 3. Verify that under Message clusters the following are checked: o Similar files o Similar URLs o Multiple similar attributes Note: At the time of publication, Soft delete is the only available remediation action and is selected by default. Because the action cannot be changed, verifying the selected remediation action is not part of the audit criteria for this recommendation.", + "AdditionalInformation": "", + "DefaultValue": "By default automatic remediation for message clusters is disabled.", + "References": "https://learn.microsoft.com/en-us/defender-office-365/air-auto-remediation:https://learn.microsoft.com/en-us/defender-office-365/air-about:https://learn.microsoft.com/en-us/exchange/security-and-compliance/recoverable-items-folder/recoverable-items-folder:https://learn.microsoft.com/en-us/exchange/recipients-in-exchange-online/manage-user-mailboxes/change-deleted-item-retention" + } + ] + }, + { + "Id": "3.1.1", + "Description": "When audit log search is enabled in the Microsoft Purview compliance portal, user and admin activity within the organization is recorded in the audit log and retained for 180 days by default. However, some organizations may prefer to use a third-party security information and event management (SIEM) application to access their auditing data. In this scenario, a global admin can choose to turn off audit log search in Microsoft 365.", + "Checks": [ + "purview_audit_log_search_enabled" + ], + "Attributes": [ + { + "Section": "3 Microsoft Purview", + "SubSection": "3.1 Audit", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "When audit log search is enabled in the Microsoft Purview compliance portal, user and admin activity within the organization is recorded in the audit log and retained for 180 days by default. However, some organizations may prefer to use a third-party security information and event management (SIEM) application to access their auditing data. In this scenario, a global admin can choose to turn off audit log search in Microsoft 365.", + "RationaleStatement": "Enabling audit log search in the Microsoft Purview compliance portal can help organizations improve their security posture, meet regulatory compliance requirements, respond to security incidents, and gain valuable operational insights.", + "ImpactStatement": "", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Purview https://purview.microsoft.com/ 2. Select Solutions and then Audit to open the audit search. 3. Click blue bar Start recording user and admin activity. 4. Click Yes on the dialog box to confirm. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Purview https://purview.microsoft.com/ 2. Select Solutions and then Audit to open the audit search. 3. Choose a date and time frame in the past 30 days. 4. Verify search capabilities (e.g. try searching for Activities as Accessed file and results should be displayed). To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-AdminAuditLogConfig | Select-Object UnifiedAuditLogIngestionEnabled 3. Ensure UnifiedAuditLogIngestionEnabled is set to True. Note: If the Get-AdminAuditLogConfig cmdlet is executed while connected to both Security & Compliance PowerShell as well as Exchange Online PowerShell then UnifiedAuditLogIngestionEnabled will always display False. This depends on the orders the module were imported. If Security & Compliance is needed in the same session be sure to connect to it first, and then Exchange PowerShell second.", + "AdditionalInformation": "", + "DefaultValue": "180 days", + "References": "https://learn.microsoft.com/en-us/purview/audit-log-enable-disable?view=o365-worldwide&tabs=microsoft-purview-portal:https://learn.microsoft.com/en-us/powershell/module/exchange/set-adminauditlogconfig?view=exchange-ps:https://learn.microsoft.com/en-us/purview/audit-log-enable-disable?view=o365-worldwide&tabs=microsoft-purview-portal#verify-the-auditing-status-for-your-organization" + } + ] + }, + { + "Id": "3.2.1", + "Description": "Data Loss Prevention (DLP) policies allow Exchange Online and SharePoint Online content to be scanned for specific types of data like social security numbers, credit card numbers, or passwords.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Microsoft Purview", + "SubSection": "3.2 Data Loss Protection", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Data Loss Prevention (DLP) policies allow Exchange Online and SharePoint Online content to be scanned for specific types of data like social security numbers, credit card numbers, or passwords.", + "RationaleStatement": "Enabling DLP policies alerts users and administrators that specific types of data should not be exposed, helping to protect the data from accidental exposure.", + "ImpactStatement": "Enabling a Teams DLP policy will allow sensitive data in Exchange Online and SharePoint Online to be detected or blocked. Always ensure to follow appropriate procedures during testing and implementation of DLP policies based on organizational standards.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Purview https://purview.microsoft.com/ 2. Click Solutions > Data loss prevention then Policies. 3. Click Create policy. 4. Create a policy that is specific to the types of data the organization wishes to protect.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Purview https://purview.microsoft.com/ 2. Click Solutions > Data loss prevention and then Policies. 3. Inspect the list of policies and verify the following criteria: o A policy exists that meets the organizations DLP needs o Mode is On 4. Open the policy and verify there is at least one of the following locations defined: o Exchange email o SharePoint sites o OneDrive accounts o Teams chat and channel messages 5. Compliance is met when there is at least one policy the meets the above criteria. To audit using the PowerShell: 1. Connect to the Security & Compliance PowerShell using Connect-IPPSSession. 2. Execute the following cmdlet to get a list of DLP Policies: Get-DlpCompliancePolicy 3. For each policy returned verify the following criteria: o Mode is Enable o At least one of the following locations is defined: o ExchangeLocation o SharePointLocation o OneDriveLocation o TeamsLocation 4. Compliance is met when there is at least one policy the meets the above criteria. Note: The types of policies an organization should implement to protect information are specific to their industry. However, certain types of information, such as credit card numbers, social security numbers, and certain personally identifiable information (PII), are universally important to safeguard across all industries.", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/purview/dlp-learn-about-dlp?view=o365-worldwide" + } + ] + }, + { + "Id": "3.2.2", + "Description": "The default Teams Data Loss Prevention (DLP) policy rule in Microsoft 365 is a preconfigured rule that is automatically applied to all Teams conversations and channels. The default rule helps prevent accidental sharing of sensitive information by detecting and blocking certain types of content that are deemed sensitive or inappropriate by the organization. By default, the rule includes a check for the sensitive info type Credit Card Number which is pre-defined by Microsoft.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Microsoft Purview", + "SubSection": "3.2 Data Loss Protection", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "The default Teams Data Loss Prevention (DLP) policy rule in Microsoft 365 is a preconfigured rule that is automatically applied to all Teams conversations and channels. The default rule helps prevent accidental sharing of sensitive information by detecting and blocking certain types of content that are deemed sensitive or inappropriate by the organization. By default, the rule includes a check for the sensitive info type Credit Card Number which is pre-defined by Microsoft.", + "RationaleStatement": "Enabling the default Teams DLP policy rule in Microsoft 365 helps protect an organization's sensitive information by preventing accidental sharing or leakage of Credit Card information in Teams conversations and channels. DLP rules are not one size fits all, but at a minimum something should be defined. The organization should identify sensitive information important to them and seek to intercept it using DLP.", + "ImpactStatement": "End-users may be prevented from sharing certain types of content, which may require them to adjust their behavior or seek permission from administrators to share specific content. Administrators may receive requests from end-users for permission to share certain types of content or to modify the policy to better fit the needs of their teams.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Purview compliance portal https://purview.microsoft.com/ 2. Under Solutions select Data loss prevention then Policies. 3. Click Policies tab. 4. Check Default policy for Teams then click Edit policy. 5. The edit policy window will appear click Next 6. At the Choose locations to apply the policy page, turn the status toggle to On for Teams chat and channel messages location and then click Next. 7. On Customized advanced DLP rules page, ensure the Default Teams DLP policy rule Status is On and click Next. 8. On the Policy mode page, select the radial for Turn it on right away and click Next. 9. Review all the settings for the created policy on the Review your policy and create it page, and then click submit. 10. Once the policy has been successfully submitted click Done. Note: Some tenants may not have a default policy for teams as Microsoft started creating these by default at a particular point in time. In this case a new policy will have to be created that includes a rule to protect data important to the organization such as credit cards and PII.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Purview compliance portal https://purview.microsoft.com/ 2. Under Solutions select Data loss prevention then Policies. 3. Locate the Default policy for Teams. 4. Verify the Status is On. 5. Verify Locations include Teams chat and channel messages - All accounts. 6. Verify Policy settings includes the Default Teams DLP policy rule or one specific to the organization. Note: If there is not a default policy for teams inspect existing policies starting with step 4. DLP rules are specific to the organization and each organization should take steps to protect the data that matters to them. The default teams DLP rule will only alert on Credit Card matches. To audit using PowerShell: 1. Connect to the Security & Compliance PowerShell using Connect-IPPSSession. 2. Run the following to return policies that include Teams chat and channel messages: $DlpPolicy = Get-DlpCompliancePolicy $DlpPolicy | Where-Object {$_.Workload -match \"Teams\"} | ft Name,Mode,TeamsLocation* 3. If nothing returns, then there are no policies that include Teams and remediation is required. 4. For any returned policy verify Mode is set to Enable. 5. Verify TeamsLocation includes All. 6. Verify TeamsLocationException includes only permitted exceptions. Note: Some tenants may not have a default policy for teams as Microsoft started creating these by default at a particular point in time. In this case a new policy will have to be created that includes a rule to protect data important to the organization such as credit cards and PII.", + "AdditionalInformation": "", + "DefaultValue": "Enabled (On)", + "References": "https://learn.microsoft.com/en-us/powershell/exchange/connect-to-scc-powershell?view=exchange-ps:https://learn.microsoft.com/en-us/purview/dlp-teams-default-policy:https://learn.microsoft.com/en-us/powershell/module/exchange/connect-ippssession?view=exchange-ps" + } + ] + }, + { + "Id": "3.2.3", + "Description": "Microsoft Purview Data Loss Prevention (DLP) policies can be scoped to Microsoft 365 Copilot and Copilot Chat interactions. When active, these policies can restrict Copilot from processing or surfacing content that matches configured sensitive information types. Organizations must define the sensitive data categories relevant to their environment and configure at least one DLP policy that covers Copilot interactions in enforcement mode. The recommended state is to configure at least one DLP policy that includes Microsoft 365 Copilot and Copilot Chat - All accounts as a location with rules specific to the organization's needs.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Microsoft Purview", + "SubSection": "3.2 Data Loss Protection", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft Purview Data Loss Prevention (DLP) policies can be scoped to Microsoft 365 Copilot and Copilot Chat interactions. When active, these policies can restrict Copilot from processing or surfacing content that matches configured sensitive information types. Organizations must define the sensitive data categories relevant to their environment and configure at least one DLP policy that covers Copilot interactions in enforcement mode. The recommended state is to configure at least one DLP policy that includes Microsoft 365 Copilot and Copilot Chat - All accounts as a location with rules specific to the organization's needs.", + "RationaleStatement": "Microsoft 365 Copilot can retrieve, summarize, and generate content based on data the authenticated user has access to across M365 workloads, including SharePoint, OneDrive, Teams, and Exchange. Without a DLP policy scoped to Copilot interactions, no technical control exists to prevent sensitive information such as PII, financial data, or health records from being incorporated into Copilot-generated responses and potentially exposed to users who would not otherwise have direct access to the source content. Enforcing DLP policies for Copilot ensures that sensitive data categories defined by the organization are intercepted before they are processed or surfaced by AI-generated responses.", + "ImpactStatement": "Users may find that Copilot declines to process or respond to prompts that involve content matching the organization's configured sensitive information types. In these cases, Copilot will notify the user that the request was blocked by policy. Users who rely on Copilot to summarize, draft, or retrieve content containing sensitive data such as documents with PII, financial records, or health information may need to rephrase their prompts or work with the content directly outside of Copilot. Administrators should communicate the scope of active DLP policies to affected users prior to enforcement.", + "RemediationProcedure": "To remediate using the UI: Note: Microsoft provides a guided wizard to create the Default DLP policy - Protect sensitive M365 Copilot interactions policy, which can be used when no Copilot DLP policy exists in the tenant. The steps below describe how to create a custom policy from scratch with Copilot included as a location. 1. Navigate to Microsoft Purview compliance portal https://purview.microsoft.com/ 2. Under Solutions select Data loss prevention then Policies. 3. Click Policies tab. 4. Click + Create Policy. 5. Click on Enterprise applications & devices. 6. Under Categories select Custom and then Custom policy for the regulation. 7. Name the policy, and if appropriate, select an Admin Unit. 8. In Locations select Microsoft 365 Copilot and Copilot Chat. 9. Click on Next to proceed to the Advanced DLP rules page. 10. Click on + Create Rule 1. Name the rule and give a brief description of the data that is being targeted. 2. Click on + Add condition and select Content Contains 3. Click on Add and select Sensitive info types 4. Select the sensitive information types the organization wants to protect from being processed in Copilot interactions and click Add. 5. Click on + Add an action and select Restrict Copilot from processing content 6. Check the box for a relevant restriction. 7. Click on Save. 11. Repeat step 10 to create as many rules as the organization requires 12. Click Next. 13. On the Policy mode page, select the radial for Turn it on right away and click Next. 14. Click Submit to create the policy once it has been reviewed. 15. Finally, click Done. Note: Compliance with this recommendation is not achieved until the policy is in enforcement mode.", + "AuditProcedure": "Note: Some tenants may have a default policy called Default DLP policy - Protect sensitive M365 Copilot interactions that was automatically created. If not present, it can also be created using a guided process in the Policies blade. If this policy exists, it may be used to satisfy the requirements of this control provided it meets the compliance criteria below. To audit using the UI: 1. Navigate to Microsoft Purview compliance portal https://purview.microsoft.com/ 2. Under Solutions select Data loss prevention then Policies. 3. Inspect the list of policies and verify the following criteria: o Locations includes Microsoft 365 Copilot and Copilot Chat - All accounts. o Mode is On. o The policy includes Rules that restrict sensitive data from being shared in Copilot interactions based on the organization's needs. 4. Compliance is met when there is at least one policy that meets the above criteria. To audit using PowerShell: 1. Connect to the Security & Compliance PowerShell using Connect-IPPSSession. 2. Run the following to return policies that include Teams chat and channel messages: $DlpPolicy = Get-DlpCompliancePolicy $DlpPolicy | Where-Object {$_.EnforcementPlanes -match \"CopilotExperiences\"} | FT Name,Mode,LocationInclusions,LocationExclusions 3. If nothing returns, then there are no policies that include Copilot and remediation is required. 4. For any returned policy verify Mode is set to Enable. 5. Verify LocationInclusions includes All. 6. Verify LocationExclusions includes only permitted exceptions. Note: DLP rules are specific to the organization and each organization should take steps to protect the data that matters to them. At a minimum, organizations should consider protecting personally identifiable information (PII) specific to their locale.", + "AdditionalInformation": "", + "DefaultValue": "No Copilot DLP policy exists by default for most tenants. Some tenants may have had a Default DLP policy - Protect sensitive M365 Copilot interactions policy automatically provisioned by Microsoft; if present, it may satisfy this recommendation if it meets the audit criteria.", + "References": "https://learn.microsoft.com/en-us/purview/dlp-microsoft365-copilot-location-learn-about:https://learn.microsoft.com/en-us/purview/dlp-microsoft365-copilot-location-default-policy:https://learn.microsoft.com/en-us/powershell/exchange/connect-to-scc-powershell?view=exchange-ps" + } + ] + }, + { + "Id": "3.3.1", + "Description": "Sensitivity labels enable organizations to classify and label content across Microsoft 365 based on its sensitivity and business impact. These labels can be applied manually by users or automatically based on the content. When applied, labels can automatically encrypt content, provide \"Confidential\" watermarks, restrict access, and offer various data protection features. Labels can be scoped to data assets and containers: - Files & other data assets in Microsoft 365, Fabric, Azure, AWS and other platforms - Email messages sent from all versions of Outlook - Meeting calendar events and schedules in Outlook and Teams - Teams, Microsoft 365 Groups and SharePoint sites", + "Checks": [], + "Attributes": [ + { + "Section": "3 Microsoft Purview", + "SubSection": "3.3 Information Protection", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Sensitivity labels enable organizations to classify and label content across Microsoft 365 based on its sensitivity and business impact. These labels can be applied manually by users or automatically based on the content. When applied, labels can automatically encrypt content, provide \"Confidential\" watermarks, restrict access, and offer various data protection features. Labels can be scoped to data assets and containers: - Files & other data assets in Microsoft 365, Fabric, Azure, AWS and other platforms - Email messages sent from all versions of Outlook - Meeting calendar events and schedules in Outlook and Teams - Teams, Microsoft 365 Groups and SharePoint sites", + "RationaleStatement": "Consistent usage of sensitivity labels can help reduce the risk of data loss or exposure and enable more effective incident response if a breach does occur. They can also help organizations comply with regulatory requirements and provide visibility and control over sensitive information.", + "ImpactStatement": "Encryption configurations (control access, DKE, BYOK) in the individual labels may impact users' ability to access site documents and information. Careful consideration of the individual sensitivity label configurations should be exercised prior to applying an auto labeling policy, publishing policy, sensitivity label configuration, or PowerShell based label settings to SharePoint sites. Additionally, when updating or deleting Sensitivity Labels, an assessment of the potential impacts should be conducted to avoid unintended consequences. If tenants are configured for sharing with guests or external domains and Sensitivity Labels have encryption applied, this can affect the ability to share documents via email stored in SharePoint. Some recipients may be unable to open the document depending on their email client, which could trigger Purview Advanced Encryptions and OME flows based on the recipient type and the cloud license from which the email is sent (e.g., government clouds vs. commercial clouds).", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Purview compliance portal https://purview.microsoft.com/ 2. Select Information protection > Sensitivity labels. 3. Click Create a label to create a label. 4. Click Publish labels and select any newly created labels to publish according to the organization's information protection needs.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Purview compliance portal https://purview.microsoft.com/ 2. Select Information protection > Policies > Label publishing policies. 3. Ensure that a Label policy exists and is published according to the organization's information protection needs. To audit using PowerShell: 1. Connect to the Security & Compliance PowerShell using Connect-IPPSSession. 2. Run the following script: $Policies = Get-LabelPolicy -WarningAction Ignore | Where-Object { $_.Type -eq \"PublishedSensitivityLabel\" } if ($Policies) { $Policies | Format-List -Property Name, *Location* Write-Host \"$($Policies.Count) Sensitivity Label policies found.\" } else { Write-Host \"No Sensitivity Label policies found\" } 3. Ensure there is at least one sensitivity label policy published. 4. Review the locations defined to ensure they're in scope with the organization's needs. Note: These policies are specific to the information protection needs of each organization. Whether an organization passes the audit is open to interpretation by the auditor and depends largely on how effectively it implements information protection features to safeguard data.", + "AdditionalInformation": "", + "DefaultValue": "The \"Global sensitivity label policy\" exists by default.", + "References": "https://learn.microsoft.com/en-us/purview/sensitivity-labels:https://learn.microsoft.com/en-us/purview/create-sensitivity-labels" + } + ] + }, + { + "Id": "4.1", + "Description": "Compliance policies are sets of rules and conditions that are used to evaluate the configuration of managed devices. These policies can help secure organizational data and resources from devices that don't meet those configuration requirements. Managed devices must satisfy the conditions you set in your policies to be considered compliant by Intune. When combined with conditional access, this allows more control over how non-compliant devices are treated. The recommended state is Mark devices with no compliance policy assigned as as Not compliant", + "Checks": [ + "intune_device_compliance_policy_unassigned_devices_not_compliant_by_default" + ], + "Attributes": [ + { + "Section": "4 Microsoft Intune admin center", + "SubSection": "", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Compliance policies are sets of rules and conditions that are used to evaluate the configuration of managed devices. These policies can help secure organizational data and resources from devices that don't meet those configuration requirements. Managed devices must satisfy the conditions you set in your policies to be considered compliant by Intune. When combined with conditional access, this allows more control over how non-compliant devices are treated. The recommended state is Mark devices with no compliance policy assigned as as Not compliant", + "RationaleStatement": "Implementing this setting is a first step in adopting compliance policies for devices. When used together with Conditional Access policies the attack surface can be reduced by forcing an action to be taken for non-compliant devices. Note: This section does not focus on which compliance policies to use, only that an organization should adopt and enforce them to their needs.", + "ImpactStatement": "Any devices without a compliance policy will be marked not compliant. Care should be taken to first deploy any new compliance policies with a Conditional Access (CA) policy that is in the Report-only state. After the environment's device compliance is better understood it is then appropriate to finally align with Mark devices with no compliance policy assigned as and enable any CA policies that enforce actions based on device compliance. If a mature environment already has an existing device compliance CA policy and a large number of devices without an assigned compliance policy, this could cause disruption as those devices would then be suddenly considered not compliant.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Intune admin center https://intune.microsoft.com/ 2. Select Devices and then under Manage devices click Compliance 3. Click Compliance settings. 4. Set Mark devices with no compliance policy assigned as to Not compliant. To remediate using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"DeviceManagementConfiguration.ReadWrite.All\" 2. Run the following commands: $Uri = 'https://graph.microsoft.com/v1.0/deviceManagement' $Body = @{ settings = @{ secureByDefault = $true } } | ConvertTo-Json Invoke-MgGraphRequest -Uri $Uri -Method PATCH -Body $Body", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Intune admin center https://intune.microsoft.com/ 2. Select Devices and then under Manage devices click Compliance 3. Click Compliance settings. 4. Verify that Mark devices with no compliance policy assigned as is set to Not compliant. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"DeviceManagementConfiguration.Read.All\" 2. Run the following commands: $Uri = 'https://graph.microsoft.com/v1.0/deviceManagement/settings' Invoke-MgGraphRequest -Uri $Uri -Method GET 3. Verify that secureByDefault is set to True.", + "AdditionalInformation": "", + "DefaultValue": "UI: \"Compliant\" Graph: secureByDefault = $false", + "References": "https://learn.microsoft.com/en-us/mem/intune/protect/device-compliance-get-started" + } + ] + }, + { + "Id": "4.2", + "Description": "Device enrollment restrictions let you restrict devices from enrolling in Intune based on certain device attributes such as device limit, device platform, OS Version, manufacturer or device ownership (Personally owned devices). The recommended state is to Block personally owned devices from enrollment.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Microsoft Intune admin center", + "SubSection": "", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Device enrollment restrictions let you restrict devices from enrolling in Intune based on certain device attributes such as device limit, device platform, OS Version, manufacturer or device ownership (Personally owned devices). The recommended state is to Block personally owned devices from enrollment.", + "RationaleStatement": "Restricting the enrollment of personally owned devices prevents attackers who have bypassed other controls from registering a new device to gain an additional foothold, further hiding or obscuring their activities. An attack path could be: 1. Account Compromise via Phishing and AiTM 2. Conditional Access Bypass 3. Reconnaissance using e.g. ROADrecon, GraphRunner or AADInternals 4. Lateral Movement, Privilege Escalation or Persistence through a newly registered device enrolled in Intune", + "ImpactStatement": "Per platform personally owned device enrollment impacts are listed below. It is important to test the changes to the defaults prior to moving into production and implementing this control. Windows Devices The following enrollment methods are authorized for corporate enrollment for Windows devices, any other enrollment method will be considered \"Personal\" and blocked: - The device enrolls through Windows Autopilot. - The device enrolls through GPO, or automatic enrollment from Configuration Manager for co-management. - The device enrolls through a bulk provisioning package. - The enrolling user is using a device enrollment manager account. MacOS By default, Intune classifies macOS devices as personally owned. To be classified as corporate-owned, a Mac must fulfill one of the following conditions: - Registered with a serial number. - Enrolled via Apple Automated Device Enrollment (ADE). iOS/IPadOS devices By default, Intune classifies iOS/iPadOS devices as personally owned. To be classified as corporate-owned, an iOS/iPadOS device must fulfill one of the following conditions: - Registered with a serial number or IMEI. - Enrolled by using Automated Device Enrollment (formerly Device Enrollment Program). Android devices By default, until you manually make changes in the admin center, your Android Enterprise work profile device settings and Android device administrator device settings are the same. If you block Android Enterprise work profile enrollment on personal devices, only corporate-owned devices can enroll with personally owned work profiles.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Intune admin center https://intune.microsoft.com/ 2. Select Devices and then under Device onboarding click Enrollment 3. Under Enrollment options select Device platform restriction. 4. Inspect the policies listed under Device type restrictions o For the Default priority policy, click All Users. o Select Properties. 5. Click Edit to change Platform settings. 6. In the Personally owned column set each platform to Block. Note: Blocking platforms that are not used in the organization is a more restrictive best practice and will also effectively block enrollment of personally owned devices for the selected platform, ensuring compliance for this recommendation.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Intune admin center https://intune.microsoft.com/ 2. Select Devices and then under Device onboarding click Enrollment 3. Under Enrollment options select Device platform restriction. 4. Inspect the policies listed under Device type restrictions o For the Default priority policy, click All Users. o Select Properties. 5. Verify that all platforms are set to Block in the Personally owned column. 6. If the Platform itself is set to Block for any of the platforms shown this is also a passing state for that platform. Note: Blocking platforms that are not used in the organization is a more restrictive best practice and will also effectively block enrollment of personally owned devices for the selected platform, ensuring compliance for this recommendation. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"DeviceManagementConfiguration.Read.All\" 2. Run the following script: $Uri = 'https://graph.microsoft.com/beta/deviceManagement/deviceEnrollmentConfigurat ions' $Config = (Invoke-MgGraphRequest -Uri $Uri -Method GET).value | Where-Object { $_.id -match 'DefaultPlatformRestrictions' -and $_.priority - eq 0 } $Result = [PSCustomObject]@{ WindowsPersonalDeviceEnrollmentBlocked = $Config.windowsRestriction.personalDeviceEnrollmentBlocked iOSPersonalDeviceEnrollmentBlocked = $Config.iosRestriction.personalDeviceEnrollmentBlocked AndroidForWorkPersonalDeviceEnrollmentBlocked = $Config.androidForWorkRestriction.personalDeviceEnrollmentBlocked MacOPersonalDeviceEnrollmentBlocked = $Config.macOSRestriction.personalDeviceEnrollmentBlocked AndroidPersonalDeviceEnrollmentBlocked = $Config.androidRestriction.personalDeviceEnrollmentBlocked } $Result 3. Inspect the output, ensure each platform displays True next to its property. A passing output will look like the below: WindowsPersonalDeviceEnrollmentBlocked : True iOSPersonalDeviceEnrollmentBlocked : True AndroidForWorkPersonalDeviceEnrollmentBlocked : True MacOPersonalDeviceEnrollmentBlocked : True AndroidPersonalDeviceEnrollmentBlocked : True Note: If platformBlocked is true then that platform is also in compliance as the platform is blocked from enrollment entirely. This is not currently reflected in the audit script but can be queried from the same API call.", + "AdditionalInformation": "", + "DefaultValue": "Allow", + "References": "https://learn.microsoft.com/en-us/mem/intune/enrollment/enrollment-restrictions-set:https://www.glueckkanja.com/blog/security/2025/01/compliant-device-bypass-en/" + } + ] + }, + { + "Id": "5.1.2.1", + "Description": "Legacy per-user Multi-Factor Authentication (MFA) can be configured to require individual users to provide multiple authentication factors, such as passwords and additional verification codes, to access their accounts. It was introduced in earlier versions of Office 365, prior to the more comprehensive implementation of Conditional Access (CA).", + "Checks": [ + "entra_users_mfa_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.2 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Manual", + "Description": "Legacy per-user Multi-Factor Authentication (MFA) can be configured to require individual users to provide multiple authentication factors, such as passwords and additional verification codes, to access their accounts. It was introduced in earlier versions of Office 365, prior to the more comprehensive implementation of Conditional Access (CA).", + "RationaleStatement": "Both security defaults and conditional access with security defaults turned off are not compatible with per-user multi-factor authentication (MFA), which can lead to undesirable user authentication states. The CIS Microsoft 365 Benchmark explicitly employs Conditional Access for MFA as an enhancement over security defaults and as a replacement for the outdated per-user MFA. To ensure a consistent authentication state disable per-user MFA on all accounts.", + "ImpactStatement": "Accounts using per-user MFA will need to be migrated to use CA. Prior to disabling per-user MFA the organization must be prepared to implement conditional access MFA to avoid security gaps and allow for a smooth transition. This will help ensure relevant accounts are covered by MFA during the change phase from disabling per-user MFA to enabling CA MFA. Section 5.2.2 in this document covers the creation of a CA rule for both administrators and all users in the tenant. Microsoft has documentation on migrating from per-user MFA Convert users from per- user MFA to Conditional Access based MFA", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Users and select All users. 3. Click on Per-user MFA on the top row. 4. Click the empty box next to Display Name to select all accounts. 5. On the far right under quick steps click Disable.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Users and select All users. 3. Click on Per-user MFA on the top row. 4. Verify that the Multi-factor Auth Status column shows Disabled for each account. To audit using Microsoft Graph 1. Determine the id or userPrincipalName of the user being audited. 2. Execute a GET request to the following relative URI: beta/users/{id | userPrincipalName}/authentication/requirements # Example https://graph.microsoft.com/beta/users/071cc716-8147-4397-a5ba- b2105951cc0b/authentication/requirements 3. Verify that the perUserMfaState property is set to disabled. 4. Repeat this process for all users within the tenant. Note: This API is in beta and does not support a list operation. To prevent server-side throttling, clients should implement batching and client-side rate limiting when auditing medium to large sized environments.", + "AdditionalInformation": "", + "DefaultValue": "Disabled", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/howto-mfa-userstates#convert-users-from-per-user-mfa-to-conditional-access:https://learn.microsoft.com/en-us/microsoft-365/admin/security-and-compliance/set-up-multi-factor-authentication?view=o365-worldwide#use-conditional-access-policies:https://learn.microsoft.com/en-us/entra/identity/authentication/howto-mfa-userstates#convert-per-user-mfa-enabled-and-enforced-users-to-disabled:https://learn.microsoft.com/en-us/graph/api/authentication-get?view=graph-rest-beta" + } + ] + }, + { + "Id": "5.1.2.2", + "Description": "This setting controls whether standard users can register applications in the Microsoft Entra ID directory. When enabled, any user can create app registrations, which function as identity objects for applications.", + "Checks": [ + "entra_thirdparty_integrated_apps_not_allowed" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.2 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting controls whether standard users can register applications in the Microsoft Entra ID directory. When enabled, any user can create app registrations, which function as identity objects for applications.", + "RationaleStatement": "Allowing standard users to create app registrations expands the tenant's attack surface. A compromised account or malicious insider could create a rogue app registration to establish a persistent OAuth client, facilitate token theft, or impersonate a legitimate application. Restricting app registration to privileged roles ensures that new application identities in the directory are subject to administrative review and approval before they can be granted permissions to organizational resources.", + "ImpactStatement": "End users will no longer be able to register applications independently, including both third-party integrations and custom applications. Developers and IT staff who create app registrations as part of normal workflows will be affected and will need to submit registration requests to a privileged administrator (e.g., Application Administrator or Cloud Application Administrator). Organizations should establish a formal request and approval process before implementing this change to avoid workflow disruption.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Users and select Users settings. 3. Set Users can register applications to No. 4. Click Save. To remediate using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.ReadWrite.Authorization\" 2. Run the following commands: $param = @{ AllowedToCreateApps = $false } Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions $param", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Users and select Users settings. 3. Verify that Users can register applications is set to No. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following command: (Get-MgPolicyAuthorizationPolicy).DefaultUserRolePermissions | fl AllowedToCreateApps 3. Verify the returned value is False.", + "AdditionalInformation": "", + "DefaultValue": "Yes (Users can register applications.)", + "References": "https://learn.microsoft.com/en-us/entra/identity-platform/how-applications-are-added:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/delegate-app-roles#restrict-who-can-create-applications" + } + ] + }, + { + "Id": "5.1.2.3", + "Description": "Non-privileged users can create tenants in the Microsoft Entra ID and Microsoft Entra administration portal under \"Manage tenant\". The creation of a tenant is recorded in the Audit log as category \"DirectoryManagement\" and activity \"Create Company\". By default, the user who creates a Microsoft Entra tenant is automatically assigned the Global Administrator role. The newly created tenant doesn't inherit any settings or configurations.", + "Checks": [ + "entra_policy_ensure_default_user_cannot_create_tenants" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.2 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Non-privileged users can create tenants in the Microsoft Entra ID and Microsoft Entra administration portal under \"Manage tenant\". The creation of a tenant is recorded in the Audit log as category \"DirectoryManagement\" and activity \"Create Company\". By default, the user who creates a Microsoft Entra tenant is automatically assigned the Global Administrator role. The newly created tenant doesn't inherit any settings or configurations.", + "RationaleStatement": "Restricting tenant creation prevents unauthorized or uncontrolled deployment of resources and ensures that the organization retains control over its infrastructure. User generation of shadow IT could lead to multiple, disjointed environments that can make it difficult for IT to manage and secure the organization's data, especially if other users in the organization began using these tenants for business purposes under the misunderstanding that they were secured by the organization's security team.", + "ImpactStatement": "Non-admin users will need to contact I.T. if they have a valid reason to create a tenant.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Entra ID > Users and select User settings. 3. Set Restrict non-admin users from creating tenants to Yes then Save. To remediate using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.ReadWrite.Authorization\" 2. Run the following commands: # Create hashtable and update the auth policy $params = @{ AllowedToCreateTenants = $false } Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions $params", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Entra ID > Users and select User settings. 3. Verify that Restrict non-admin users from creating tenants is set to Yes To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following commands: (Get-MgPolicyAuthorizationPolicy).DefaultUserRolePermissions | Select-Object AllowedToCreateTenants 3. Verify the returned value is False", + "AdditionalInformation": "", + "DefaultValue": "No - Non-administrators can create tenants. AllowedToCreateTenants is True", + "References": "https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions#restrict-member-users-default-permissions:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#tenant-creator" + } + ] + }, + { + "Id": "5.1.2.4", + "Description": "This setting restricts non-administrators from loading a set of frequently visited pages in the Microsoft Entra admin center and Azure portal, including home, tenant overview, and the users list. What does it not do? - It does not block programmatic access to Microsoft Entra data via PowerShell, Microsoft Graph API, or other tools like Visual Studio. - It does not apply to users with an administrative role, including custom roles. - It does not prevent all access to the admin center. Many areas are still reachable through alternate paths.", + "Checks": [ + "entra_admin_portals_access_restriction" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.2 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Manual", + "Description": "This setting restricts non-administrators from loading a set of frequently visited pages in the Microsoft Entra admin center and Azure portal, including home, tenant overview, and the users list. What does it not do? - It does not block programmatic access to Microsoft Entra data via PowerShell, Microsoft Graph API, or other tools like Visual Studio. - It does not apply to users with an administrative role, including custom roles. - It does not prevent all access to the admin center. Many areas are still reachable through alternate paths.", + "RationaleStatement": "The Microsoft Entra admin center contains sensitive data and permission settings, which are still enforced based on the user's role. However, an end user may inadvertently change properties or account settings on their own account. This could result in increased administrative overhead. Additionally, a compromised end-user account could be used to enumerate tenant structure, users, and group memberships to support privilege escalation or lateral movement.", + "ImpactStatement": "Non-administrators who own groups will be unable to reach group management pages through the standard admin center navigation. Self-service access to other portal-facing features may also be affected depending on the navigation path used. Because the restriction targets specific frequently accessed pages rather than all portal content, users with direct (deep) links to other admin center sections may still be able to access them. Note: Users will still be able to sign into Microsoft Entra admin center but will be unable to see directory information.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Entra ID > Users and select User settings. 3. Set Restrict access to Microsoft Entra admin center to Yes then Save.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Entra ID > Users and select User settings. 3. Verify under the Administration center section that Restrict access to Microsoft Entra admin center is set to Yes.", + "AdditionalInformation": "", + "DefaultValue": "No - Non-administrators can access the Microsoft Entra admin center.", + "References": "https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions#restrict-member-users-default-permissions" + } + ] + }, + { + "Id": "5.1.2.5", + "Description": "The option for the user to Stay signed in, or the Keep me signed in option, will prompt a user after a successful login. When the user selects this option, a persistent refresh token is created. The refresh token lasts for 90 days by default and does not prompt for sign-in or multifactor.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.2 Users", + "Profile": "E3 Level 2", + "AssessmentStatus": "Manual", + "Description": "The option for the user to Stay signed in, or the Keep me signed in option, will prompt a user after a successful login. When the user selects this option, a persistent refresh token is created. The refresh token lasts for 90 days by default and does not prompt for sign-in or multifactor.", + "RationaleStatement": "Allowing users to select this option presents risk, especially if the user signs into their account on a publicly accessible computer/web browser. In this case it would be trivial for an unauthorized person to gain access to any associated cloud data from that account.", + "ImpactStatement": "Once this setting is hidden users will no longer be prompted upon sign-in with the message Stay signed in?. This may mean users will be forced to sign in more frequently. Important: some features of SharePoint Online and Office 2010 have a dependency on users remaining signed in. If you hide this option, users may get additional and unexpected sign in prompts.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Users and select User settings. 3. Set Show keep user signed in to No. 4. Click Save.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Users and select User settings. 3. Verify that Show keep user signed in is highlighted No.", + "AdditionalInformation": "", + "DefaultValue": "Users may select stay signed in", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/concepts-azure-multi-factor-authentication-prompts-session-lifetime:https://learn.microsoft.com/en-us/entra/fundamentals/how-to-manage-stay-signed-in-prompt" + } + ] + }, + { + "Id": "5.1.2.6", + "Description": "LinkedIn account connections allow users to connect their Microsoft work or school account with LinkedIn. After a user connects their accounts, information and highlights from LinkedIn are available in some Microsoft apps and services.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.2 Users", + "Profile": "E3 Level 2", + "AssessmentStatus": "Manual", + "Description": "LinkedIn account connections allow users to connect their Microsoft work or school account with LinkedIn. After a user connects their accounts, information and highlights from LinkedIn are available in some Microsoft apps and services.", + "RationaleStatement": "Disabling LinkedIn integration prevents potential phishing attacks and risk scenarios where an external party could accidentally disclose sensitive information.", + "ImpactStatement": "Users will not be able to sync contacts or use LinkedIn integration.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Users and select User settings. 3. Under LinkedIn account connections select No. 4. Click Save.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Users and select User settings. 3. Under LinkedIn account connections, verify that No is selected.", + "AdditionalInformation": "", + "DefaultValue": "LinkedIn integration is enabled by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/users/linkedin-integration:https://learn.microsoft.com/en-us/entra/identity/users/linkedin-user-consent" + } + ] + }, + { + "Id": "5.1.3.1", + "Description": "This setting allows users in the organization to create new security groups and add members to these groups in the Azure portal, API, or PowerShell. These new groups also show up in the Access Panel for all other users. If the policy setting on the group allows it, other users can create requests to join these groups. The recommended state is Users can create security groups in Azure portals, API or PowerShell set to No.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.3 Groups", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting allows users in the organization to create new security groups and add members to these groups in the Azure portal, API, or PowerShell. These new groups also show up in the Access Panel for all other users. If the policy setting on the group allows it, other users can create requests to join these groups. The recommended state is Users can create security groups in Azure portals, API or PowerShell set to No.", + "RationaleStatement": "Allowing end users to create security groups without oversight can lead to uncontrolled group sprawl, increasing the risk of inappropriate access to sensitive data. The default assignment of group ownership to the creator introduces a potential for privilege escalation, especially if IT teams overlook how these groups are later used to manage access. A more malicious scenario arises when a compromised non-privileged user creates deceptively named security groups such as \"Accounting\" or \"Break-glass\", or uses homograph techniques to mimic legitimate group names. Third-party IT teams may be particularly susceptible, as they might not be familiar with the environment or lack consistent naming conventions. An unsuspecting administrator could then mistakenly assign elevated privileges, grant access to sensitive data, or exclude these groups from Conditional Access policies, inadvertently creating a serious security gap.", + "ImpactStatement": "Restrictions may introduce some operational friction, particularly in fast-paced or decentralized environments where teams rely on self-service capabilities for collaboration and access management. This can increase reliance on IT teams for routine tasks, potentially causing delays. However, these impacts can be minimized through automated approval workflows and clear governance processes.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Groups and select General. 3. Set Users can create security groups in Azure portals, API or PowerShell to No. 4. Click Save. To remediate using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.ReadWrite.Authorization\" 2. Run the following commands: $params = @{ defaultUserRolePermissions = @{ AllowedToCreateSecurityGroups = $false } } Update-MgPolicyAuthorizationPolicy -BodyParameter $params", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Groups and select General. 3. Verify that Users can create security groups in Azure portals, API or PowerShell is set to No. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following command: (Get-MgPolicyAuthorizationPolicy).DefaultUserRolePermissions | fl 3. Verify that AllowedToCreateSecurityGroups is False.", + "AdditionalInformation": "", + "DefaultValue": "AllowedToCreateSecurityGroups : True", + "References": "https://learn.microsoft.com/en-us/entra/identity/users/groups-self-service-management?WT.mc_id=Portal-Microsoft_AAD_IAM#group-settings:https://learn.microsoft.com/en-us/graph/api/authorizationpolicy-get?view=graph-rest-1.0&tabs=http:https://learn.microsoft.com/en-us/entra/identity/users/groups-self-service-management#making-a-group-available-for-end-user-self-service" + } + ] + }, + { + "Id": "5.1.3.2", + "Description": "This setting restricts standard users from accessing the My Groups web interface in the My Account portal (https://myaccount.microsoft.com/groups). When set to Yes, this web interface access is removed for standard users. The recommended state is Yes.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.3 Groups", + "Profile": "E3 Level 2", + "AssessmentStatus": "Manual", + "Description": "This setting restricts standard users from accessing the My Groups web interface in the My Account portal (https://myaccount.microsoft.com/groups). When set to Yes, this web interface access is removed for standard users. The recommended state is Yes.", + "RationaleStatement": "By default, any authenticated user can access the My Groups portal and enumerate group memberships, SharePoint site URLs, group email addresses, Teams URLs, and Yammer URLs across the tenant. This information enables reconnaissance, where a user could identify high-value or privileged groups, map resource URLs, and use that data to plan further attacks or lateral movement. Restricting the web interface limits passive enumeration by users who do not require group browsing as part of their duties, reducing the available attack surface without impacting core productivity. Note: This setting applies only to the My Groups web interface. API-based enumeration remains possible for users with appropriate permissions or tooling, and this control should not be treated as a complete enumeration defense.", + "ImpactStatement": "Setting this to Yes creates administrative overhead for users who need to look up group memberships and must now request that information from an administrator.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Groups and select General. 3. Under Self Service Group Management, set Restrict user ability to access groups features in My Groups. Group and User Admin will have read-only access when the value of this setting is 'Yes' to Yes. 4. Click Save.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Groups and select General. 3. Under Self Service Group Management, verify that Restrict user ability to access groups features in My Groups. Group and User Admin will have read-only access when the value of this setting is 'Yes' is set to Yes.", + "AdditionalInformation": "", + "DefaultValue": "No", + "References": "https://learn.microsoft.com/en-us/entra/identity/users/groups-self-service-management" + } + ] + }, + { + "Id": "5.1.3.3", + "Description": "Microsoft Entra ID provides self-service group management features that enable users to create and manage their own security groups or Microsoft 365 groups. The owner of the group can approve or deny membership requests and delegate control of group membership. Self-service group management features aren't available for mail-enabled security groups or distribution lists. The recommended state is No.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.3 Groups", + "Profile": "E3 Level 1", + "AssessmentStatus": "Manual", + "Description": "Microsoft Entra ID provides self-service group management features that enable users to create and manage their own security groups or Microsoft 365 groups. The owner of the group can approve or deny membership requests and delegate control of group membership. Self-service group management features aren't available for mail-enabled security groups or distribution lists. The recommended state is No.", + "RationaleStatement": "Group owners are standard users who may not have visibility into access governance requirements for a given group. Allowing owners to approve membership requests through My Groups means additions to security groups or Microsoft 365 groups can occur without administrator review, bypassing formal access provisioning controls. Unauthorized or excessive group membership can expand a user's effective permissions and increase the blast radius of a compromised account.", + "ImpactStatement": "Administrators will be responsible for managing group membership requests instead of group owners, which is the default behavior. Administrative overhead will only increase if this setting was previously changed to Yes.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Groups select General. 3. Set Owners can manage group membership requests in My Groups to No. 4. Click Save.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Groups and select General. 3. Verify that Owners can manage group membership requests in My Groups is set to No", + "AdditionalInformation": "", + "DefaultValue": "No", + "References": "https://learn.microsoft.com/en-us/entra/identity/users/groups-self-service-management#making-a-group-available-for-end-user-self-service" + } + ] + }, + { + "Id": "5.1.3.4", + "Description": "All users within a Microsoft Entra organization are permitted to create new Microsoft 365 groups and add members to those groups through the Azure portal, API, or PowerShell. Newly created groups also appear in the Access Panel for all other users. When the applicable group policy settings allow it, users can submit requests to join these groups. The recommended state is No.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.3 Groups", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "All users within a Microsoft Entra organization are permitted to create new Microsoft 365 groups and add members to those groups through the Azure portal, API, or PowerShell. Newly created groups also appear in the Access Panel for all other users. When the applicable group policy settings allow it, users can submit requests to join these groups. The recommended state is No.", + "RationaleStatement": "Restricting Microsoft 365 group creation to administrators only ensures that creation of Microsoft 365 groups is controlled by the administrator. Appropriate groups should be created and managed by the administrator and group creation rights should not be delegated to any other user.", + "ImpactStatement": "Enabling this setting could create a number of requests that would need to be managed by an administrator.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Groups and select General. 3. Set Users can create Microsoft 365 groups in Azure portals, API or PowerShell to No 4. Click Save. To remediate using the Microsoft Graph API: 1. Execute a PATCH request to the following relative URI: v1.0/groupSettings 2. Target the object with the templateId of 62375ab9-6b52-47ed-826b- 58e47e0e304b 3. Update EnableGroupCreation to false. Note: If a group with the above templateId doesn't exist this means the defaults are present and it would be advisable to use the UI to remediate, as this will automatically create the Group.Unified object with its defaults. Microsoft's documentation does cover using a POST request to build this using the API, however.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Groups and select General. 3. Verify that Users can create Microsoft 365 groups in Azure portals, API or PowerShell is set to No To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/groupSettings 2. Filter to groups with the templateId of 62375ab9-6b52-47ed-826b- 58e47e0e304b 3. Verify that EnableGroupCreation is false. 4. If the group with the above templateId does not exist, then it means the setting is in its default state and is not compliant.", + "AdditionalInformation": "", + "DefaultValue": "Yes", + "References": "https://learn.microsoft.com/en-us/microsoft-365/solutions/manage-creation-of-groups:https://learn.microsoft.com/en-us/graph/api/group-list-settings?view=graph-rest-0&tabs=http:https://learn.microsoft.com/en-us/graph/api/groupsetting-update?view=graph-rest-1.0&tabs=http" + } + ] + }, + { + "Id": "5.1.4.1", + "Description": "This setting enables you to select the users who can register their devices as Microsoft Entra joined devices. The recommended state is Selected or None. Note: This setting is applicable only to Microsoft Entra join on Windows 10 or newer. This setting doesn't apply to Microsoft Entra hybrid joined devices, Microsoft Entra joined VMs in Azure, or Microsoft Entra joined devices that use Windows Autopilot self- deployment mode because these methods work in a userless context.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.4 Devices", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "This setting enables you to select the users who can register their devices as Microsoft Entra joined devices. The recommended state is Selected or None. Note: This setting is applicable only to Microsoft Entra join on Windows 10 or newer. This setting doesn't apply to Microsoft Entra hybrid joined devices, Microsoft Entra joined VMs in Azure, or Microsoft Entra joined devices that use Windows Autopilot self- deployment mode because these methods work in a userless context.", + "RationaleStatement": "If a threat actor compromises a standard user account, they can enroll a rogue device under that user's identity. This device may inherit MDM policies and appear compliant, giving attackers persistent access to cloud resources without triggering MFA. In a 2023 blog, Microsoft IR reports that it has detected threat actors registering their own devices to the Microsoft Entra tenant, giving them a platform to escalate the cyberattack. While simply joining a device to a Microsoft Entra tenant may present limited immediate risk, it could allow a threat actor to establish a foothold in the environment.", + "ImpactStatement": "Restricting the setting requires IT teams to assign enrollment permissions to specific staff, such as helpdesk or provisioning personnel, which may impact user-driven Autopilot scenarios and increase administrative overhead for device onboarding and support.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Set Users may join devices to Microsoft Entra to Selected (and add members) or None.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Verify that Users may join devices to Microsoft Entra is set to Selected or None. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: beta/policies/deviceRegistrationPolicy 3. Verify that azureADJoin.allowedToJoin.@odata.type is one of the following: o #microsoft.graph.enumeratedDeviceRegistrationMembership (Selected) o #microsoft.graph.noDeviceRegistrationMembership (None). Note: When set to Selected, users and groups will also appear in the output of the Graph Request.", + "AdditionalInformation": "", + "DefaultValue": "All", + "References": "https://learn.microsoft.com/en-us/entra/identity/devices/manage-device-identities#configure-device-settings:https://www.microsoft.com/en-us/security/blog/2023/12/05/microsoft-incident-response-lessons-on-preventing-cloud-identity-compromise/#poor-device:https://learn.microsoft.com/en-us/graph/api/resources/deviceregistrationpolicy?view=graph-rest-beta" + } + ] + }, + { + "Id": "5.1.4.2", + "Description": "This setting defines the maximum number of Microsoft Entra joined or registered devices that a user can have in Microsoft Entra ID. Once this limit is reached, no additional devices can be added until existing ones are removed. Values above 100 are automatically capped at 100. The recommended state is 10 or less.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.4 Devices", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting defines the maximum number of Microsoft Entra joined or registered devices that a user can have in Microsoft Entra ID. Once this limit is reached, no additional devices can be added until existing ones are removed. Values above 100 are automatically capped at 100. The recommended state is 10 or less.", + "RationaleStatement": "Microsoft incident response teams have observed threat actors enrolling their own devices to establish persistence after a non-privileged user has been compromised. High device quotas can exacerbate this risk by enabling attackers to register multiple devices that appear legitimate, while also contributing to unmanaged or personal devices cluttering the environment, driving up licensing costs and complicating compliance efforts. Enforcing a reasonable device limit per user supports good governance, reduces the attack surface, and encourages administrators to reassess and clean up legacy or unused device enrollments.", + "ImpactStatement": "IT staff who need to enroll more than 10 devices on behalf of the organization must be assigned the role of Device Enrollment Manager in the Intune admin center. Device Enrollment Managers are non-administrator accounts that can enroll and manage up to 1,000 devices. It is recommended to use dedicated service accounts for this role rather than assigning it to users' primary or daily-use accounts. Warning: Do not delete accounts assigned as a Device enrollment manager if any devices were enrolled using the account. Doing so will lead to issues with these devices.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Set Maximum number of devices per user to 10 or less.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Verify that Maximum number of devices per user is set to 10 or less. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/policies/deviceRegistrationPolicy 2. Verify that userDeviceQuota is 10 or less.", + "AdditionalInformation": "", + "DefaultValue": "50", + "References": "https://learn.microsoft.com/en-us/entra/identity/devices/manage-device-identities#configure-device-settings:https://learn.microsoft.com/en-us/intune/intune-service/enrollment/device-enrollment-manager-enroll:https://learn.microsoft.com/en-us/graph/api/resources/deviceregistrationpolicy?view=graph-rest-beta" + } + ] + }, + { + "Id": "5.1.4.3", + "Description": "This setting controls whether the Global Administrator role is automatically added to the local administrators group on a device during the Microsoft Entra join process. The recommended state is No.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.4 Devices", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting controls whether the Global Administrator role is automatically added to the local administrators group on a device during the Microsoft Entra join process. The recommended state is No.", + "RationaleStatement": "System administrators may be inclined to use over-privileged accounts for convenience when managing devices. Enforcing this control helps discourage that behavior by requiring administrative actions to be performed using accounts specifically designated for local administration. This promotes adherence to the principle of least privilege and reduces the risk associated with using high-level roles for routine tasks. For example, using a Global Administrator account to authenticate to a compromised endpoint and continue performing tasks significantly increases the risk of broader organizational compromise.", + "ImpactStatement": "Restricting the default behavior and requiring manual assignment to least privilege roles introduces minor administrative overhead. During the Microsoft Entra join process, the Microsoft Entra Joined Device Local Administrator role is automatically added to the device's local administrators group and should be used instead.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Set Global administrator role is added as local administrator on the device during Microsoft Entra join (Preview) to No.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Verify that Global administrator role is added as local administrator on the device during Microsoft Entra join (Preview) is set to No. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: beta/policies/deviceRegistrationPolicy 2. Verify that azureADJoin.localAdmins.enableGlobalAdmins is False.", + "AdditionalInformation": "", + "DefaultValue": "Yes", + "References": "https://learn.microsoft.com/en-us/entra/identity/devices/manage-device-identities#configure-device-settings:https://learn.microsoft.com/en-us/graph/api/resources/deviceregistrationpolicy?view=graph-rest-beta:https://learn.microsoft.com/en-us/entra/identity/devices/assign-local-admin" + } + ] + }, + { + "Id": "5.1.4.4", + "Description": "This setting determines if the Microsoft Entra user registering their device as Microsoft Entra join will be added to the local administrators group. This setting applies only once during the actual registration of the device as Microsoft Entra join. The recommended state is Selected or None.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.4 Devices", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting determines if the Microsoft Entra user registering their device as Microsoft Entra join will be added to the local administrators group. This setting applies only once during the actual registration of the device as Microsoft Entra join. The recommended state is Selected or None.", + "RationaleStatement": "To uphold the principle of least privilege, the assignment of local administrator rights during Microsoft Entra join should be centrally managed using appropriate built-in roles through Intune. This approach minimizes the number of disparate users with elevated privileges, reducing the attack surface and potential for misuse. Centralized management also streamlines the deprovisioning process, ensuring that administrative access can be revoked efficiently and consistently across all devices, rather than requiring manual intervention on each individual endpoint.", + "ImpactStatement": "Restricting the default behavior and requiring manual assignment to built-in roles introduces minor administrative overhead. During the Microsoft Entra join process, the Microsoft Entra Joined Device Local Administrator role is automatically added to the device's local administrators group and should be used instead.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Set Registering user is added as local administrator on the device during Microsoft Entra join (Preview) to Selected (and add members) or None.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Verify that Registering user is added as local administrator on the device during Microsoft Entra join (Preview) is set to Selected or None. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: beta/policies/deviceRegistrationPolicy 2. Verify that azureADJoin.localAdmins.registeringUsers.@odata.type is one of the following: o #microsoft.graph.enumeratedDeviceRegistrationMembership (Selected) o #microsoft.graph.noDeviceRegistrationMembership (None). Note: When set to Selected, users and groups will also appear in the output of the Graph Request.", + "AdditionalInformation": "", + "DefaultValue": "All", + "References": "https://learn.microsoft.com/en-us/entra/identity/devices/manage-device-identities#configure-device-settings:https://learn.microsoft.com/en-us/graph/api/resources/deviceregistrationpolicy?view=graph-rest-beta:https://learn.microsoft.com/en-us/entra/identity/devices/assign-local-admin" + } + ] + }, + { + "Id": "5.1.4.5", + "Description": "Local Administrator Password Solution (LAPS) is the management of local account passwords on Windows devices. LAPS provides a solution to securely manage and retrieve the built-in local admin password. With cloud version of LAPS, customers can enable storing and rotation of local admin passwords for both Microsoft Entra and Microsoft Entra hybrid join devices The recommended state is Yes.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.4 Devices", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Local Administrator Password Solution (LAPS) is the management of local account passwords on Windows devices. LAPS provides a solution to securely manage and retrieve the built-in local admin password. With cloud version of LAPS, customers can enable storing and rotation of local admin passwords for both Microsoft Entra and Microsoft Entra hybrid join devices The recommended state is Yes.", + "RationaleStatement": "Managing local Administrator passwords across multiple systems can be challenging. As a result, many organizations opt to configure the same password on all workstations and/or member servers during deployment. However, this practice introduces a significant security risk: if an attacker compromises one system and obtains the local Administrator password, they can potentially gain administrative access to every other system using that same password. Additionally, enabling LAPS at the tenant level is a prerequisite for implementing LAPS- related recommendations outlined in the CIS Microsoft Intune for Windows Workstation Benchmarks. Note: Enabling LAPS at the tenant level does not automatically enforce password rotation for built-in Administrator accounts. To activate LAPS functionality, appropriate policies must be configured in Intune Settings Catalog or under the Endpoint security > Account protection blade. The CIS Microsoft 365 Foundations Benchmark focuses on hardening at the tenant level, while the CIS Intune Benchmarks focus on endpoint-specific configurations.", + "ImpactStatement": "Enabling LAPS requires some additional operational overhead. Although unlikely if a password is rotated and not retrieved or backed up before the device becomes unreachable (e.g., due to hardware failure, network isolation, or being decommissioned), administrators may be locked out.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Set Enable Microsoft Entra Local Administrator Password Solution (LAPS) to Yes.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Verify that Enable Microsoft Entra Local Administrator Password Solution (LAPS) is set to Yes. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/policies/deviceRegistrationPolicy 2. Verify that localAdminPassword.isEnabled is True.", + "AdditionalInformation": "", + "DefaultValue": "No", + "References": "https://learn.microsoft.com/en-us/entra/identity/devices/manage-device-identities#configure-device-settings:https://learn.microsoft.com/en-us/graph/api/resources/deviceregistrationpolicy?view=graph-rest-beta:https://learn.microsoft.com/en-us/entra/identity/devices/howto-manage-local-admin-passwords" + } + ] + }, + { + "Id": "5.1.4.6", + "Description": "This setting determines if users can self-service recover their BitLocker key(s). 'Yes' restricts non-admin users from being able to see the BitLocker key(s) for their owned devices if there are any. 'No' allows all users to recover their BitLocker key(s). The recommended state is Yes.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.4 Devices", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "This setting determines if users can self-service recover their BitLocker key(s). 'Yes' restricts non-admin users from being able to see the BitLocker key(s) for their owned devices if there are any. 'No' allows all users to recover their BitLocker key(s). The recommended state is Yes.", + "RationaleStatement": "Restricting user access to the self-service BitLocker recovery key portal helps mitigate the risk of recovery key exposure in the event of a compromised user account. If an attacker gains access to both the user's credentials and the physical device, they could potentially retrieve the recovery key and decrypt sensitive data. The recovery key itself is also considered sensitive information.", + "ImpactStatement": "Restricting this setting will increase administrative overhead and may introduce friction between end users and the helpdesk, as users will no longer be able to retrieve BitLocker recovery keys through the self-service portal. This portal was originally designed to streamline recovery and reduce support burden. During the CrowdStrike Falcon Sensor outage in July 2024, many endpoints entered recovery mode, and delays in accessing recovery keys contributed to prolonged downtime. Limiting self-service access could exacerbate such delays in future incidents, especially in large or distributed environments.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Set Restrict users from recovering the BitLocker key(s) for their owned devices to Yes. To remediate using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.ReadWrite.Authorization\" 2. Run the following: $params = @{ defaultUserRolePermissions = @{ AllowedToReadBitlockerKeysForOwnedDevice = $false } } Update-MgPolicyAuthorizationPolicy -BodyParameter $params", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Verify that Restrict users from recovering the BitLocker key(s) for their owned devices is set to Yes. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following: (Get-MgPolicyAuthorizationPolicy).DefaultUserRolePermissions | fl 3. Verify that AllowedToReadBitlockerKeysForOwnedDevice is False.", + "AdditionalInformation": "", + "DefaultValue": "No", + "References": "https://learn.microsoft.com/en-us/entra/identity/devices/manage-device-identities#configure-device-settings:https://learn.microsoft.com/en-us/graph/api/authorizationpolicy-get?view=graph-rest-1.0:https://techcommunity.microsoft.com/blog/intunecustomersuccess/user-self-service-bitlocker-recovery-key-access-with-intune-company-portal-websi/4150458:https://learn.microsoft.com/en-us/windows/security/operating-system-security/data-protection/bitlocker/recovery-process#self-recovery" + } + ] + }, + { + "Id": "5.1.5.1", + "Description": "Control when end users and group owners are allowed to grant consent to applications, and when they will be required to request administrator review and approval. Allowing users to grant apps access to data helps them acquire useful applications and be productive but can represent a risk in some situations if it's not monitored and controlled carefully.", + "Checks": [ + "entra_policy_restricts_user_consent_for_apps" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.5 Enterprise apps", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Control when end users and group owners are allowed to grant consent to applications, and when they will be required to request administrator review and approval. Allowing users to grant apps access to data helps them acquire useful applications and be productive but can represent a risk in some situations if it's not monitored and controlled carefully.", + "RationaleStatement": "Attackers commonly use custom applications to trick users into granting them access to company data. Restricting user consent mitigates this risk and helps to reduce the threat-surface.", + "ImpactStatement": "If user consent is disabled, previous consent grants will still be honored but all future consent operations must be performed by an administrator. Tenant-wide admin consent can be requested by users through an integrated administrator consent request workflow or through organizational support processes.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Consent and permissions > User consent settings. 4. Under User consent for applications select Do not allow user consent. 5. Click the Save option at the top of the window.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Consent and permissions > User consent settings. 4. Verify that User consent for applications is set to Do not allow user consent. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following command: (Get-MgPolicyAuthorizationPolicy).DefaultUserRolePermissions | Select-Object -ExpandProperty PermissionGrantPoliciesAssigned 3. Verify that the returned array does not contain either ManagePermissionGrantsForSelf.microsoft-user-default-low or ManagePermissionGrantsForSelf.microsoft-user-default-legacy. If either of these strings is present, the audit fails.", + "AdditionalInformation": "", + "DefaultValue": "UI - Allow user consent for apps", + "References": "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-user-consent?pivots=portal:https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.identity.signins/get-mgpolicyauthorizationpolicy?view=graph-powershell-1.0" + } + ] + }, + { + "Id": "5.1.5.2", + "Description": "The admin consent workflow gives admins a secure way to grant access to applications that require admin approval. When a user tries to access an application but is unable to provide consent, they can send a request for admin approval. The request is sent via email to admins who have been designated as reviewers. A reviewer takes action on the request, and the user is notified of the action.", + "Checks": [ + "entra_admin_consent_workflow_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.5 Enterprise apps", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "The admin consent workflow gives admins a secure way to grant access to applications that require admin approval. When a user tries to access an application but is unable to provide consent, they can send a request for admin approval. The request is sent via email to admins who have been designated as reviewers. A reviewer takes action on the request, and the user is notified of the action.", + "RationaleStatement": "The admin consent workflow (Preview) gives admins a secure way to grant access to applications that require admin approval. When a user tries to access an application but is unable to provide consent, they can send a request for admin approval. The request is sent via email to admins who have been designated as reviewers. A reviewer acts on the request, and the user is notified of the action.", + "ImpactStatement": "To approve requests, a reviewer must be a global administrator, cloud application administrator, or application administrator. The reviewer must already have one of these admin roles assigned; simply designating them as a reviewer doesn't elevate their privileges.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Consent and permissions. 4. Under Manage select Admin consent settings. 5. Set Users can request admin consent to apps they are unable to consent to to Yes under Admin consent requests. 6. Under the Reviewers choose the Roles and Groups that will review user generated app consent requests. 7. Set Selected users will receive email notifications for requests to Yes 8. Select Save at the top of the window.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Consent and permissions. 4. Under Manage select Admin consent settings. 5. Verify that Users can request admin consent to apps they are unable to consent to is set to Yes. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following command: Get-MgPolicyAdminConsentRequestPolicy | fl IsEnabled,NotifyReviewers,RemindersEnabled 3. Verify that IsEnabled is True.", + "AdditionalInformation": "", + "DefaultValue": "- Users can request admin consent to apps they are unable to consent to: No - Selected users to review admin consent requests: None - Selected users will receive email notifications for requests: Yes - Selected users will receive request expiration reminders: Yes - Consent request expires after (days): 30", + "References": "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-admin-consent-workflow" + } + ] + }, + { + "Id": "5.1.5.3", + "Description": "In Microsoft Entra ID, applications and service principals can authenticate using either certificate credentials or password credentials (also referred to as client secrets). This setting enforces a tenant-wide restriction that prevents new password credentials from being added to any application registration or service principal. The policy is implemented through the default app management policy and applies to all applications unless scoped exceptions are configured. The setting does not revoke or invalidate existing password credentials; credentials created before the policy was enabled remain valid until they expire or are explicitly removed. The recommended state is Block password addition set to On.", + "Checks": [ + "entra_default_app_management_policy_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.5 Enterprise apps", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "In Microsoft Entra ID, applications and service principals can authenticate using either certificate credentials or password credentials (also referred to as client secrets). This setting enforces a tenant-wide restriction that prevents new password credentials from being added to any application registration or service principal. The policy is implemented through the default app management policy and applies to all applications unless scoped exceptions are configured. The setting does not revoke or invalidate existing password credentials; credentials created before the policy was enabled remain valid until they expire or are explicitly removed. The recommended state is Block password addition set to On.", + "RationaleStatement": "Password credentials (client secrets) used for application authentication are static string values that offer weaker security guarantees than certificate or federated credentials. Unlike certificates, client secrets carry no built-in proof of possession and are frequently stored in plaintext in source code, configuration files, CI/CD pipelines, and shell history. A leaked client secret grants any holder the ability to authenticate as the application to Microsoft Entra ID, potentially accessing any resource or permission scope assigned to that application. Blocking the addition of new password credentials eliminates this attack surface for applications created going forward and forces adoption of stronger credential types such as certificates.", + "ImpactStatement": "This policy applies to new password credential additions only. Existing client secrets remain valid until they expire or are explicitly revoked; this recommendation does not retroactively invalidate credentials created before the policy was enabled. Any automated process, pipeline, or script that programmatically adds client secrets to application registrations or service principals will be blocked once the policy is enabled, unless an exception is configured. Applications that have not yet migrated to certificate- based authentication or workload identity federation will require changes before new credentials can be added.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Application policies. 4. Select Block password addition. 5. Set Status to On. 6. Set Applies to to one of the following: o All applications o All applications with exclusions (if using exclusions, ensure they are reviewed annually). 7. Set Only apply to apps created after to a desired date or leave it unconfigured. 8. Select Save and close to apply the changes. To remediate using the Microsoft Graph API: Important: The PATCH request replaces the passwordCredentials array in full. Retrieve the current policy first and include all existing entries in the request body to avoid overwriting other configured restrictions or exclusions. 1. Execute a GET request to retrieve the current policy: v1.0/policies/defaultAppManagementPolicy 2. Modify the returned JSON to reflect the following changes: - Set isEnabled to true. - Under applicationRestrictions.passwordCredentials, locate the entry where restrictionType is passwordAddition and set the following: o state to enabled o restrictForAppsCreatedAfterDateTime to 0001-01-01T00:00:00Z or a desired date. - Under servicePrincipalRestrictions.passwordCredentials, locate the entry where restrictionType is passwordAddition and set the following: o state to enabled o restrictForAppsCreatedAfterDateTime to 0001-01-01T00:00:00Z or a desired date. 3. Execute a PATCH request to the same URI with the modified JSON in the request body to apply the changes. Note: The References section includes a link to the API documentation with full remediation examples in multiple languages including HTTP, PowerShell, and Python.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Application policies. 4. Select Block password addition. 5. Verify that Status is set to On. 6. Verify that Applies to is set to one of the following: o All applications o All applications with exclusions (if using exclusions, verify they are reviewed annually). 7. Verify that Only apply to apps created after is one of the following states: o Not configured (Field will show Select a date with no date selected) o A date that is on or before the date of the assessment. 8. Compliance is met when all of the above conditions are satisfied. To audit using the Microsoft Graph API: Note: Both the application restrictions and service principal restrictions must be audited to ensure password addition is properly blocked, as they can be independently configured in Graph or PowerShell. The UI only surfaces the combined state of both settings. 1. Execute a GET request to the following relative URI to get the default app management policy: v1.0/policies/defaultAppManagementPolicy 2. Verify that isEnabled is true, this indicates that the default app management policy is enabled and being applied to the tenant. Part 1: Audit application restrictions 1. Under applicationRestrictions.passwordCredentials, locate the entry where restrictionType is passwordAddition. 2. Verify the following conditions are met for applicationRestrictions.passwordCredentials: o state is enabled o restrictForAppsCreatedAfterDateTime is one of the following states: - 0001-01-01T00:00:00Z (not configured) - A date that is on or before the date of the assessment. 3. Compliance is met when all of the above conditions are satisfied. Part 2: Audit service principal restrictions 1. Under servicePrincipalRestrictions.passwordCredentials, locate the entry where restrictionType is passwordAddition. 2. Verify the following conditions are met for servicePrincipalRestrictions.passwordCredentials: o state is enabled o restrictForAppsCreatedAfterDateTime is one of the following states: - 0001-01-01T00:00:00Z (not configured) - A date that is on or before the date of the assessment. 3. Compliance is met when all of the above conditions are satisfied. Note: Presently the API does not surface application exclusions, only excluded callers, so it is not necessary to audit them for compliance.", + "AdditionalInformation": "", + "DefaultValue": "Off", + "References": "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-app-management-policies?tabs=portal:https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/tutorial-enforce-secret-standards?pivots=ms-graph:https://learn.microsoft.com/en-us/graph/api/resources/tenantappmanagementpolicy?view=graph-rest-1.0:https://learn.microsoft.com/en-us/entra/fundamentals/zero-trust-protect-identities#enforce-standards-for-app-secrets-and-certificates" + } + ] + }, + { + "Id": "5.1.5.4", + "Description": "In Microsoft Entra ID, applications and service principals can authenticate using password credentials (also referred to as client secrets). This setting enforces a tenant- wide maximum lifetime for new password credentials added to any application registration or service principal. When enabled, any client secret created must have an expiration date that falls within the configured maximum, which for this recommendation is 180 days or less. The policy is implemented through the default app management policy and applies to all applications unless scoped exceptions are configured. The setting does not retroactively shorten or invalidate existing password credentials; secrets created before the policy was enabled remain valid until they expire or are explicitly removed. The recommended state is Restrict max password lifetime set to On: 180 days or less.", + "Checks": [ + "entra_default_app_management_policy_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.5 Enterprise apps", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "In Microsoft Entra ID, applications and service principals can authenticate using password credentials (also referred to as client secrets). This setting enforces a tenant- wide maximum lifetime for new password credentials added to any application registration or service principal. When enabled, any client secret created must have an expiration date that falls within the configured maximum, which for this recommendation is 180 days or less. The policy is implemented through the default app management policy and applies to all applications unless scoped exceptions are configured. The setting does not retroactively shorten or invalidate existing password credentials; secrets created before the policy was enabled remain valid until they expire or are explicitly removed. The recommended state is Restrict max password lifetime set to On: 180 days or less.", + "RationaleStatement": "Long-lived client secrets extend the window of exploitation if a credential is compromised. A secret valid for multiple years that is never rotated remains usable even if it was leaked in source code, a build log, or a security breach long after the initial exposure. Enforcing a maximum lifetime of 180 days ensures that client secrets expire on a regular basis, limiting the period during which a stolen credential remains valid and reducing the blast radius of a compromise. This control also encourages teams to establish automated rotation practices, which further reduces reliance on static, long- lived credentials.", + "ImpactStatement": "Any automated process, pipeline, or script that creates client secrets with a lifetime exceeding the configured maximum will fail once the policy is enabled, unless an exception is configured. Organizations will need to update secret creation workflows to specify expiration dates within the allowed range and establish rotation processes for secrets approaching expiry.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Application policies. 4. Select Restrict max password lifetime. 5. Set Status to On. 6. Set the maximum lifetime to 180 days or less. 7. Set Applies to to one of the following: o All applications o All applications with exclusions (if using exclusions, ensure they are reviewed annually). 8. Set Only apply to apps created after to a desired date or leave it unconfigured. 9. Select Save and close to apply the changes. To remediate using the Microsoft Graph API: Important: The PATCH request replaces the passwordCredentials array in full. Retrieve the current policy first and include all existing entries in the request body to avoid overwriting other configured restrictions or exclusions. 1. Execute a GET request to retrieve the current policy: v1.0/policies/defaultAppManagementPolicy 2. Modify the returned JSON to reflect the following changes: o Set isEnabled to true. o Under applicationRestrictions.passwordCredentials, locate the entry where restrictionType is passwordLifetime and set the following: - state to enabled - maxLifetime to P180D or a shorter duration. - restrictForAppsCreatedAfterDateTime to 0001-01- 01T00:00:00Z or a desired date. o Under servicePrincipalRestrictions.passwordCredentials, locate the entry where restrictionType is passwordLifetime and set the following: - state to enabled - maxLifetime to P180D or a shorter duration. - restrictForAppsCreatedAfterDateTime to 0001-01- 01T00:00:00Z or a desired date. 3. Execute a PATCH request to the same URI with the modified JSON in the request body to apply the changes. Note: The References section includes a link to the API documentation with full remediation examples in multiple languages including HTTP, PowerShell, and Python.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Application policies. 4. Select Restrict max password lifetime. 5. Verify that Status is set to On. 6. Verify that the configured maximum lifetime is 180 days or less. 7. Verify that Applies to is set to one of the following: o All applications o All applications with exclusions (if using exclusions, verify they are reviewed annually). 8. Verify that Only apply to apps created after is one of the following states: o Not configured (Field will show Select a date with no date selected) o A date that is on or before the date of the assessment. 9. Compliance is met when all of the above conditions are satisfied. To audit using the Microsoft Graph API: Note: Both the application restrictions and service principal restrictions must be audited to ensure the password lifetime is properly restricted, as they can be independently configured in Graph or PowerShell. The UI only surfaces the combined state of both settings. 1. Execute a GET request to the following relative URI to get the default app management policy: v1.0/policies/defaultAppManagementPolicy 2. Verify that isEnabled is true, this indicates that the default app management policy is enabled and being applied to the tenant. Part 1: Audit application restrictions 1. Under applicationRestrictions.passwordCredentials, locate the entry where restrictionType is passwordLifetime. 2. Verify the following conditions are met for applicationRestrictions.passwordCredentials: o state is enabled o maxLifetime is P180D or a shorter duration (e.g., P90D, P30D, etc.) o restrictForAppsCreatedAfterDateTime is one of the following states: - 0001-01-01T00:00:00Z (not configured) - A date that is on or before the date of the assessment. 3. Compliance is met when all of the above conditions are satisfied. Part 2: Audit service principal restrictions 1. Under servicePrincipalRestrictions.passwordCredentials, locate the entry where restrictionType is passwordLifetime. 2. Verify the following conditions are met for servicePrincipalRestrictions.passwordCredentials: o state is enabled o maxLifetime is P180D or a shorter duration (e.g., P90D, P30D, etc.) o restrictForAppsCreatedAfterDateTime is one of the following states: - 0001-01-01T00:00:00Z (not configured) - A date that is on or before the date of the assessment. 3. Compliance is met when all of the above conditions are satisfied. Note: Presently the API does not surface application exclusions, only excluded callers, so it is not necessary to audit them for compliance.", + "AdditionalInformation": "", + "DefaultValue": "Off", + "References": "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-app-management-policies?tabs=portal:https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/tutorial-enforce-secret-standards?pivots=ms-graph:https://learn.microsoft.com/en-us/graph/api/resources/tenantappmanagementpolicy?view=graph-rest-1.0:https://learn.microsoft.com/en-us/entra/fundamentals/zero-trust-protect-identities#enforce-standards-for-app-secrets-and-certificates" + } + ] + }, + { + "Id": "5.1.5.5", + "Description": "In Microsoft Entra ID, applications and service principals can authenticate using password credentials (also referred to as client secrets). By default, when adding a new password credential, the caller may supply a custom password value or allow the system to generate one. This setting enforces a tenant-wide restriction that blocks the use of custom password values, requiring all new password credentials to be system- generated. The policy is implemented through the default app management policy and applies to all applications unless scoped exceptions are configured. The setting does not affect existing password credentials; credentials created before the policy was enabled remain valid until they expire or are explicitly removed. The recommended state is Block custom passwords set to On.", + "Checks": [ + "entra_default_app_management_policy_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.5 Enterprise apps", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "In Microsoft Entra ID, applications and service principals can authenticate using password credentials (also referred to as client secrets). By default, when adding a new password credential, the caller may supply a custom password value or allow the system to generate one. This setting enforces a tenant-wide restriction that blocks the use of custom password values, requiring all new password credentials to be system- generated. The policy is implemented through the default app management policy and applies to all applications unless scoped exceptions are configured. The setting does not affect existing password credentials; credentials created before the policy was enabled remain valid until they expire or are explicitly removed. The recommended state is Block custom passwords set to On.", + "RationaleStatement": "Custom password values are chosen by the caller and are susceptible to low entropy, predictable patterns, and reuse across multiple applications. A weak or reused client secret that is compromised through source code exposure, logging, or a supply-chain breach can be trivially exploited by an attacker to authenticate as the application. System-generated passwords use random values of sufficient length and complexity, making them resistant to brute-force and dictionary attacks. Blocking custom passwords removes the weakest credential creation path and ensures that all new client secrets meet a consistent entropy baseline.", + "ImpactStatement": "Any automated process, pipeline, or script that programmatically creates a client secret by supplying a custom password value will be blocked once the policy is enabled, unless an exception is configured. Most tooling, including the Microsoft Entra admin center, Azure CLI, and Azure PowerShell, already defaults to system-generated values, so the operational impact for typical workflows is minimal. Organizations that rely on custom password values in their automation will need to update those workflows to omit the custom value and accept the system-generated secret. Organizations that have policies or regulatory requirements that mandate specific password formats may need to maintain exclusions for certain applications. Exceptions should be scoped narrowly and reviewed regularly to minimize risk.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Application policies. 4. Select Block custom passwords. 5. Set Status to On. 6. Set Applies to to one of the following: o All applications o All applications with exclusions (if using exclusions, ensure they are reviewed annually). 7. Set Only apply to apps created after to a desired date or leave it unconfigured. 8. Select Save and close to apply the changes. To remediate using the Microsoft Graph API: Important: The PATCH request replaces the passwordCredentials array in full. Retrieve the current policy first and include all existing entries in the request body to avoid overwriting other configured restrictions or exclusions. 1. Execute a GET request to retrieve the current policy: v1.0/policies/defaultAppManagementPolicy 2. Modify the returned JSON to reflect the following changes: o Set isEnabled to true. o Under applicationRestrictions.passwordCredentials, locate the entry where restrictionType is customPasswordAddition and set the following: - state to enabled - restrictForAppsCreatedAfterDateTime to 0001-01- 01T00:00:00Z or a desired date. o Under servicePrincipalRestrictions.passwordCredentials, locate the entry where restrictionType is customPasswordAddition and set the following: - state to enabled - restrictForAppsCreatedAfterDateTime to 0001-01- 01T00:00:00Z or a desired date. 3. Execute a PATCH request to the same URI with the modified JSON in the request body to apply the changes. Note: The References section includes a link to the API documentation with full remediation examples in multiple languages including HTTP, PowerShell, and Python.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Application policies. 4. Select Block custom passwords. 5. Verify that Status is set to On. 6. Verify that Applies to is set to one of the following: o All applications o All applications with exclusions (if using exclusions, verify they are reviewed annually). 7. Verify that Only apply to apps created after is one of the following states: o Not configured (Field will show Select a date with no date selected) o A date that is on or before the date of the assessment. 8. Compliance is met when all of the above conditions are satisfied. To audit using the Microsoft Graph API: Note: Both the application restrictions and service principal restrictions must be audited to ensure custom password addition is properly blocked, as they can be independently configured in Graph or PowerShell. The UI only surfaces the combined state of both settings. 1. Execute a GET request to the following relative URI to get the default app management policy: v1.0/policies/defaultAppManagementPolicy 2. Verify that isEnabled is true, this indicates that the default app management policy is enabled and being applied to the tenant. Part 1: Audit application restrictions 1. Under applicationRestrictions.passwordCredentials, locate the entry where restrictionType is customPasswordAddition. 2. Verify the following conditions are met for applicationRestrictions.passwordCredentials: o state is enabled o restrictForAppsCreatedAfterDateTime is one of the following states: - 0001-01-01T00:00:00Z (not configured) - A date that is on or before the date of the assessment. 3. Compliance is met when all of the above conditions are satisfied. Part 2: Audit service principal restrictions 1. Under servicePrincipalRestrictions.passwordCredentials, locate the entry where restrictionType is customPasswordAddition. 2. Verify the following conditions are met for servicePrincipalRestrictions.passwordCredentials: o state is enabled o restrictForAppsCreatedAfterDateTime is one of the following states: - 0001-01-01T00:00:00Z (not configured) - A date that is on or before the date of the assessment. 3. Compliance is met when all of the above conditions are satisfied. Note: Presently the API does not surface application exclusions, only excluded callers, so it is not necessary to audit them for compliance.", + "AdditionalInformation": "", + "DefaultValue": "Off", + "References": "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-app-management-policies?tabs=portal:https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/tutorial-enforce-secret-standards?pivots=ms-graph:https://learn.microsoft.com/en-us/graph/api/resources/tenantappmanagementpolicy?view=graph-rest-1.0:https://learn.microsoft.com/en-us/entra/fundamentals/zero-trust-protect-identities#enforce-standards-for-app-secrets-and-certificates" + } + ] + }, + { + "Id": "5.1.5.6", + "Description": "In Microsoft Entra ID, applications and service principals can authenticate using certificate credentials. This setting enforces a tenant-wide maximum lifetime for new certificate credentials added to any application registration or service principal. When enabled, any certificate uploaded must have a validity period that falls within the configured maximum, which for this recommendation is 180 days or less. The policy is implemented through the default app management policy and applies to all applications unless scoped exceptions are configured. The setting does not retroactively shorten or invalidate existing certificate credentials; certificates uploaded before the policy was enabled remain valid until they expire or are explicitly removed. The recommended state is Restrict maximum certificate lifetime set to On: 180 days or less.", + "Checks": [ + "entra_default_app_management_policy_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.5 Enterprise apps", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "In Microsoft Entra ID, applications and service principals can authenticate using certificate credentials. This setting enforces a tenant-wide maximum lifetime for new certificate credentials added to any application registration or service principal. When enabled, any certificate uploaded must have a validity period that falls within the configured maximum, which for this recommendation is 180 days or less. The policy is implemented through the default app management policy and applies to all applications unless scoped exceptions are configured. The setting does not retroactively shorten or invalidate existing certificate credentials; certificates uploaded before the policy was enabled remain valid until they expire or are explicitly removed. The recommended state is Restrict maximum certificate lifetime set to On: 180 days or less.", + "RationaleStatement": "Long-lived certificates extend the window of exploitation if a credential is compromised. A certificate valid for multiple years that is never rotated remains usable even if the private key was exposed through a server breach, misconfigured storage, or supply- chain compromise long after the initial exposure. Enforcing a maximum lifetime of 180 days ensures that certificates expire on a regular basis, limiting the period during which a stolen credential remains valid and reducing the blast radius of a compromise. This control also encourages teams to establish automated certificate rotation practices, which further reduces reliance on static, long-lived credentials.", + "ImpactStatement": "Any automated process, pipeline, or script that uploads certificates with a validity period exceeding the configured maximum will be blocked once the policy is enabled, unless an exception is configured. Organizations will need to update certificate issuance workflows to generate certificates with expiration dates within the allowed range and establish rotation processes for certificates approaching expiry.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Application policies. 4. Select Restrict max certificate lifetime. 5. Set Status to On. 6. Set Applies to to one of the following: o All applications o All applications with exclusions (if using exclusions, ensure they are reviewed annually). 7. Set Only apply to apps created after to a desired date or leave it unconfigured. 8. Set Maximum lifetime (in days) to 180 days or less. 9. Select Save and close to apply the changes. To remediate using the Microsoft Graph API: 1. Execute a GET request to retrieve the current policy: v1.0/policies/defaultAppManagementPolicy 2. Modify the returned JSON to reflect the following changes: o Set isEnabled to true. o Under applicationRestrictions.keyCredentials, locate the entry where restrictionType is asymmetricKeyLifetime and set the following: - state to enabled - maxLifetime to P180D or a shorter duration. - restrictForAppsCreatedAfterDateTime to 0001-01- 01T00:00:00Z or a desired date. o Under servicePrincipalRestrictions.keyCredentials, locate the entry where restrictionType is asymmetricKeyLifetime and set the following: - state to enabled - maxLifetime to P180D or a shorter duration. - restrictForAppsCreatedAfterDateTime to 0001-01- 01T00:00:00Z or a desired date. 3. Execute a PATCH request to the same URI with the modified JSON in the request body to apply the changes. Note: The References section includes a link to the API documentation with full remediation examples in multiple languages including HTTP, PowerShell, and Python.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Application policies. 4. Select Restrict max certificate lifetime. 5. Verify that Status is set to On. 6. Verify that Applies to is set to one of the following: o All applications o All applications with exclusions (if using exclusions, verify they are reviewed annually). 7. Verify that Only apply to apps created after is one of the following states: o Not configured (Field will show Select a date with no date selected) o A date that is on or before the date of the assessment. 8. Verify that Maximum lifetime (in days) is 180 days or less. 9. Compliance is met when all of the above conditions are satisfied. To audit using the Microsoft Graph API: Note: Both the application restrictions and service principal restrictions must be audited to ensure the certificate lifetime is properly restricted, as they can be independently configured in Graph or PowerShell. The UI only surfaces the combined state of both settings. 1. Execute a GET request to the following relative URI to get the default app management policy: v1.0/policies/defaultAppManagementPolicy 2. Verify that isEnabled is true, this indicates that the default app management policy is enabled and being applied to the tenant. Part 1: Audit application restrictions 1. Under applicationRestrictions.keyCredentials, locate the entry where restrictionType is asymmetricKeyLifetime. 2. Verify the following conditions are met for applicationRestrictions.keyCredentials: o state is enabled o maxLifetime is P180D or a shorter duration (e.g., P90D, P30D, etc.) o restrictForAppsCreatedAfterDateTime is one of the following states: - 0001-01-01T00:00:00Z (not configured) - A date that is on or before the date of the assessment. 3. Compliance is met when all of the above conditions are satisfied. Part 2: Audit service principal restrictions 1. Under servicePrincipalRestrictions.keyCredentials, locate the entry where restrictionType is asymmetricKeyLifetime. 2. Verify the following conditions are met for servicePrincipalRestrictions.keyCredentials: o state is enabled o maxLifetime is P180D or a shorter duration (e.g., P90D, P30D, etc.) o restrictForAppsCreatedAfterDateTime is one of the following states: - 0001-01-01T00:00:00Z (not configured) - A date that is on or before the date of the assessment. 3. Compliance is met when all of the above conditions are satisfied. Note: Presently the API does not surface application exclusions, only excluded callers, so it is not necessary to audit them for compliance.", + "AdditionalInformation": "", + "DefaultValue": "Off", + "References": "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-app-management-policies?tabs=portal:https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/tutorial-enforce-secret-standards?pivots=ms-graph:https://learn.microsoft.com/en-us/graph/api/resources/tenantappmanagementpolicy?view=graph-rest-1.0:https://learn.microsoft.com/en-us/entra/fundamentals/zero-trust-protect-identities#enforce-standards-for-app-secrets-and-certificates" + } + ] + }, + { + "Id": "5.1.6.1", + "Description": "B2B collaboration is a feature within Microsoft Entra External ID that allows for guest invitations to an organization. Ensure users can only send invitations to specified domains. Note: This list works independently from OneDrive for Business and SharePoint Online allow/block lists. To restrict individual file sharing in SharePoint Online, set up an allow or blocklist for OneDrive for Business and SharePoint Online. For instance, in SharePoint or OneDrive users can still share with external users from prohibited domains by using Anyone links if they haven't been disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.6 External Identities", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "B2B collaboration is a feature within Microsoft Entra External ID that allows for guest invitations to an organization. Ensure users can only send invitations to specified domains. Note: This list works independently from OneDrive for Business and SharePoint Online allow/block lists. To restrict individual file sharing in SharePoint Online, set up an allow or blocklist for OneDrive for Business and SharePoint Online. For instance, in SharePoint or OneDrive users can still share with external users from prohibited domains by using Anyone links if they haven't been disabled.", + "RationaleStatement": "By specifying allowed domains for collaborations, external user's companies are explicitly identified. Also, this prevents internal users from inviting unknown external users such as personal accounts and granting them access to resources.", + "ImpactStatement": "This could make collaboration more difficult if the setting is not quickly updated when a new domain is identified as \"allowed\".", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > External Identities and select External collaboration settings. 3. Under Collaboration restrictions, select Allow invitations only to the specified domains (most restrictive) is selected. Then specify the allowed domains under Target domains.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > External Identities and select External collaboration settings. 3. Under Collaboration restrictions, verify that Allow invitations only to the specified domains (most restrictive) is selected. Then verify allowed domains are specified under Target domains. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following: $Uri = \"https://graph.microsoft.com/beta/legacy/policies\" $Response = (Invoke-MgGraphRequest -Uri $Uri).value | Where-Object { $_.type -eq 'B2BManagementPolicy' } if ($Response) { $Definition = $Response.definition | ConvertFrom-Json $DomainsPolicy = $Definition.B2BManagementPolicy.InvitationsAllowedAndBlockedDomainsPolicy } else { Write-Output \"No policy found.\" return } $DomainsPolicy 3. Verify that the output includes an AllowedDomains property that either contains no domains or lists only organizationally approved domains. If a BlockedDomains property is present, the configuration is considered non-compliant. Example of a compliant output with AllowedDomains defined: AllowedDomains -------------- {cisecurity.org, contoso.com, example.com} Allowed with no domains allowed (also compliant): AllowedDomains -------------- {}", + "AdditionalInformation": "", + "DefaultValue": "Allow invitations to be sent to any domain (most inclusive)", + "References": "https://learn.microsoft.com/en-us/entra/external-id/allow-deny-list:https://learn.microsoft.com/en-us/entra/external-id/what-is-b2b" + } + ] + }, + { + "Id": "5.1.6.2", + "Description": "Microsoft Entra ID, part of Microsoft Entra, allows you to restrict what external guest users can see in their organization in Microsoft Entra ID. Guest users are set to a limited permission level by default in Microsoft Entra ID, while the default for member users is the full set of user permissions. These directory level permissions are enforced across Microsoft Entra services including Microsoft Graph, PowerShell v2, the Azure portal, and My Apps portal. Microsoft 365 services leveraging Microsoft 365 groups for collaboration scenarios are also affected, specifically Outlook, Microsoft Teams, and SharePoint. They do not override the SharePoint or Microsoft Teams guest settings. The recommended state is at least Guest users have limited access to properties and memberships of directory objects or more restrictive.", + "Checks": [ + "entra_policy_guest_users_access_restrictions" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.6 External Identities", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra ID, part of Microsoft Entra, allows you to restrict what external guest users can see in their organization in Microsoft Entra ID. Guest users are set to a limited permission level by default in Microsoft Entra ID, while the default for member users is the full set of user permissions. These directory level permissions are enforced across Microsoft Entra services including Microsoft Graph, PowerShell v2, the Azure portal, and My Apps portal. Microsoft 365 services leveraging Microsoft 365 groups for collaboration scenarios are also affected, specifically Outlook, Microsoft Teams, and SharePoint. They do not override the SharePoint or Microsoft Teams guest settings. The recommended state is at least Guest users have limited access to properties and memberships of directory objects or more restrictive.", + "RationaleStatement": "By limiting guest access to the most restrictive state this helps prevent malicious group and user object enumeration in the Microsoft 365 environment. This first step, known as reconnaissance in The Cyber Kill Chain, is often conducted by attackers prior to more advanced targeted attacks.", + "ImpactStatement": "The default is Guest users have limited access to properties and memberships of directory objects. When using the 'most restrictive' setting, guests will only be able to access their own profiles and will not be allowed to see other users' profiles, groups, or group memberships. There are some known issues with Yammer that will prevent guests that are signed in from leaving the group.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > External Identities and select External collaboration settings. 3. Under Guest user access set Guest user access restrictions to one of the following: o Guest users have limited access to properties and memberships of directory objects o Guest user access is restricted to properties and memberships of their own directory objects (most restrictive) To remediate using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.ReadWrite.Authorization\" 2. Run the following command to set the guest user access restrictions to default: # Guest users have limited access to properties and memberships of directory objects Update-MgPolicyAuthorizationPolicy -GuestUserRoleId '10dae51f-b6af-4016-8d66- 8c2a99b929b3' 3. Or, run the following command to set it to the \"most restrictive\": # Guest user access is restricted to properties and memberships of their own directory objects (most restrictive) Update-MgPolicyAuthorizationPolicy -GuestUserRoleId '2af84b1e-32c8-42b7-82bc- daa82404023b' Note: Either setting allows for a passing state.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > External Identities and select External collaboration settings. 3. Under Guest user access verify that Guest user access restrictions is set to one of the following: o Guest users have limited access to properties and memberships of directory objects o Guest user access is restricted to properties and memberships of their own directory objects (most restrictive) To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following command: Get-MgPolicyAuthorizationPolicy | fl GuestUserRoleId 3. Verify that the value returned is 10dae51f-b6af-4016-8d66-8c2a99b929b3 or 2af84b1e-32c8-42b7-82bc-daa82404023b (most restrictive) Note: Either setting allows for a passing state. Note 2: The value of a0b1b346-4d3e-4e8b-98f8-753987be4970 is equal to Guest users have the same access as members (most inclusive) and should not be used.", + "AdditionalInformation": "", + "DefaultValue": "- UI: Guest users have limited access to properties and memberships of directory objects - PowerShell: 10dae51f-b6af-4016-8d66-8c2a99b929b3", + "References": "https://learn.microsoft.com/en-us/entra/identity/users/users-restrict-guest-permissions:https://www.lockheedmartin.com/en-us/capabilities/cyber/cyber-kill-chain.html" + } + ] + }, + { + "Id": "5.1.6.3", + "Description": "By default, all users in the organization, including B2B collaboration guest users, can invite external users to B2B collaboration. The ability to send invitations can be limited by turning it on or off for everyone, or by restricting invitations to certain roles. The recommended state is Only users assigned to specific admin roles can invite guest users or No one in the organization can invite guest users including admins (most restrictive).", + "Checks": [ + "entra_policy_guest_invite_only_for_admin_roles" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.6 External Identities", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "By default, all users in the organization, including B2B collaboration guest users, can invite external users to B2B collaboration. The ability to send invitations can be limited by turning it on or off for everyone, or by restricting invitations to certain roles. The recommended state is Only users assigned to specific admin roles can invite guest users or No one in the organization can invite guest users including admins (most restrictive).", + "RationaleStatement": "Restricting who can invite guests limits the exposure the organization might face from unauthorized accounts. The default behavior allows anyone within the organization to invite guests and non-admins to the tenant, posing a security risk.", + "ImpactStatement": "This introduces an obstacle to collaboration by restricting who can invite guest users to the organization. Designated Guest Inviters must be assigned, and an approval process established and clearly communicated to all users.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > External Identities and select External collaboration settings. 3. Under Guest invite settings set Guest invite restrictions to one of the desired compliant states: o Only users assigned to specific admin roles can invite guest users o No one in the organization can invite guest users including admins (most restrictive) To remediate using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.ReadWrite.Authorization\" 2. Run one of the following PowerShell commands depending on the desired compliant state: To set to Only users assigned to specific admin roles can invite guest users: Update-MgPolicyAuthorizationPolicy -AllowInvitesFrom 'adminsAndGuestInviters' To set to No one in the organization can invite guest users including admins (most restrictive): Update-MgPolicyAuthorizationPolicy -AllowInvitesFrom \"none\"", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > External Identities and select External collaboration settings. 3. Under Guest invite settings verify that Guest invite restrictions is set to one of the following: o Only users assigned to specific admin roles can invite guest users o No one in the organization can invite guest users including admins (most restrictive) To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following command: Get-MgPolicyAuthorizationPolicy | fl AllowInvitesFrom 3. Verify the value returned is adminsAndGuestInviters or none.", + "AdditionalInformation": "", + "DefaultValue": "- UI: Anyone in the organization can invite guest users including guests and non-admins (most inclusive) - PowerShell: everyone", + "References": "https://learn.microsoft.com/en-us/entra/external-id/external-collaboration-settings-configure:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#guest-inviter" + } + ] + }, + { + "Id": "5.1.8.1", + "Description": "Password Hash Synchronization is one of the sign-in methods used to enable hybrid identity authentication. With this method, Microsoft Entra Connect synchronizes a cryptographically derived representation of a user's on-premises Active Directory password to Microsoft Entra ID. The original NT password hash (MD4) is never transmitted to Entra ID. Instead, Entra Connect computes a SHA-256 hash of the original MD4 hash and synchronizes that value. Because only this secondary hash is stored in the cloud, the credential material in Entra ID cannot be reused for on-premises pass-the-hash attacks, even if compromised. Note: The audit and remediation procedures described in this recommendation are applicable only to Microsoft 365 tenants operating in a hybrid identity configuration using Microsoft Entra Connect. They do not apply to federated or cloud-only deployments.", + "Checks": [ + "entra_password_hash_sync_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.8 Hybrid management", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Password Hash Synchronization is one of the sign-in methods used to enable hybrid identity authentication. With this method, Microsoft Entra Connect synchronizes a cryptographically derived representation of a user's on-premises Active Directory password to Microsoft Entra ID. The original NT password hash (MD4) is never transmitted to Entra ID. Instead, Entra Connect computes a SHA-256 hash of the original MD4 hash and synchronizes that value. Because only this secondary hash is stored in the cloud, the credential material in Entra ID cannot be reused for on-premises pass-the-hash attacks, even if compromised. Note: The audit and remediation procedures described in this recommendation are applicable only to Microsoft 365 tenants operating in a hybrid identity configuration using Microsoft Entra Connect. They do not apply to federated or cloud-only deployments.", + "RationaleStatement": "Password hash synchronization helps by reducing the number of passwords your users need to maintain to just one and enables leaked credential detection for your hybrid accounts. Leaked credential protection is leveraged through Entra ID Protection and is a subset of that feature which can help identify if an organization's user account passwords have appeared on the dark web or public spaces. Using other options for your directory synchronization may be less resilient as Microsoft can still process sign-ins to 365 with Hash Sync even if a network connection to your on-premises environment is not available. This minimizes downtime and ensures business continuity.", + "ImpactStatement": "Compliance or regulatory restrictions may exist, depending on the organization's business sector, that preclude hashed versions of passwords from being securely transmitted to cloud data centers.", + "RemediationProcedure": "To remediate using the on-prem Microsoft Entra Connect tool: 1. Log in to the on premises server that hosts the Microsoft Entra Connect tool 2. Double-click the Azure AD Connect icon that was created on the desktop 3. Click Configure. 4. On the Additional tasks page, select Customize synchronization options and click Next. 5. Enter the username and password for your global administrator. 6. On the Connect your directories screen, click Next. 7. On the Domain and OU filtering screen, click Next. 8. On the Optional features screen, check Password hash synchronization and click Next. 9. On the Ready to configure screen click Configure. 10. Once the configuration completes, click Exit.", + "AuditProcedure": "To audit using the UI: Only Global Admin and Hybrid Identity Administrator roles have access to view the actual Password Hash Sync status message. Inadequate role access will result in a default message stating: \"Unable to retrieve your tenant's password hash sync information.\" 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Entra Connect. 3. Select Connect Sync. 4. Under Microsoft Entra Connect sync, verify the Password Hash Sync status message indicates that synchronization is occurring and no errors are present, with one of the following messages: o Password hash synchronization is enabled o Password hash synchronization cloud configuration is enabled o Password hash synchronization heartbeat detected To audit using the Microsoft Graph API: Permission required: OnPremDirectorySynchronization.Read.All 1. Execute a GET request to the following relative URI: v1.0/directory/onPremisesSynchronization 2. Verify that features.passwordSyncEnabled is true. To audit for the on-prem tool: 1. Log in to the server that hosts the Microsoft Entra Connect tool. 2. Run Azure AD Connect, and then click Configure and View or export current configuration. 3. Verify that PASSWORD HASH SYNCHRONIZATION is enabled on your tenant. To audit using PowerShell: 1. Open PowerShell on the on-premises server running Microsoft Entra Connect. 2. Run the following cmdlet: Get-ADSyncAADCompanyFeature 3. Verify that PasswordHashSync is True.", + "AdditionalInformation": "", + "DefaultValue": "- Microsoft Entra Connect sync disabled by default - Password Hash Sync is Microsoft's recommended setting for new deployments", + "References": "https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/whatis-phs:https://www.microsoft.com/en-us/download/details.aspx?id=47594:https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-sync-staging-server:https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-password-hash-synchronization:https://learn.microsoft.com/en-us/graph/api/resources/onpremisesdirectorysynchronizationfeature?view=graph-rest-1.0" + } + ] + }, + { + "Id": "5.2.2.1", + "Description": "Multifactor authentication is a process that requires an additional form of identification during the sign-in process, such as a code from a mobile device or a fingerprint scan, to enhance security. Ensure users in administrator roles have MFA capabilities enabled.", + "Checks": [ + "entra_admin_users_mfa_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Multifactor authentication is a process that requires an additional form of identification during the sign-in process, such as a code from a mobile device or a fingerprint scan, to enhance security. Ensure users in administrator roles have MFA capabilities enabled.", + "RationaleStatement": "Multifactor authentication requires an individual to present a minimum of two separate forms of authentication before access is granted. Multifactor authentication provides additional assurance that the individual attempting to gain access is who they claim to be. With multifactor authentication, an attacker would need to compromise at least two different authentication mechanisms, increasing the difficulty of compromise and thus reducing the risk. Note: To ensure that accounts cannot be easily used to enumerate resources (reconnaissance) through Microsoft Admin Portals or through Microsoft Azure Service Management API, both MFA conditional access policies must target All Resources: - \"Ensure multifactor authentication is enabled for all users\" and - \"Ensure multifactor authentication is enabled for all users in administrative roles\" (this recommendation)", + "ImpactStatement": "Implementation of multifactor authentication for all users in administrative roles will necessitate a change to user routine. All users in administrative roles will be required to enroll in multifactor authentication using phone, SMS, or an authentication application. After enrollment, use of multifactor authentication will be required for future access to the environment.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Click New policy. o Under Users or agents (Preview) include Select users and groups and check Directory roles. o At a minimum, include the directory roles listed below in this section of the document. o Under Target resources include All resources (formerly 'All cloud apps') and do not create any exclusions. - Under Exclude exclude any break-glass accounts. o Under Grant select Grant Access and check either Require multifactor authentication or Require authentication strength. o Click Select at the bottom of the pane. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On. At minimum these directory roles should be included for MFA: - Application administrator - Authentication administrator - Billing administrator - Cloud application administrator - Conditional Access administrator - Exchange administrator - Global administrator - Global reader - Helpdesk administrator - Password administrator - Privileged authentication administrator - Privileged role administrator - Security administrator - SharePoint administrator - User administrator", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify Directory roles specific to administrators are included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify All resources (formerly 'All cloud apps') is selected with no exclusions. o Under Grant verify Grant Access is on and either Require multifactor authentication or Require authentication strength is checked. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. For each policy returned, verify the following criteria: o Conditions.Users.IncludeRoles contains the directory roles specific to administrators* o Conditions.Applications.IncludeApplications is All o GrantControls.BuiltInControls contains mfa OR GrantControls.AuthenticationStrength.id is defined o State is enabled 3. Compliance is met when at least one policy is found to meet all the criteria listed above. 4. Verify that any exclusions are documented and reviewed annually. Note: The Authentication Strength requirement is satisfied when any valid GUID is present in the authenticationStrength property of a matching policy. Because Authentication Strength configurations are inherently stronger than the built-in Require multifactor authentication control, the presence of a valid Authentication Strength also fulfills the MFA requirement for all users. Note: A list of Directory roles can be found in the Remediation section.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-admin-mfa:https://learn.microsoft.com/en-us/graph/api/conditionalaccessroot-list-policies?view=graph-rest-1.0" + } + ] + }, + { + "Id": "5.2.2.2", + "Description": "Enable multifactor authentication for all users in the Microsoft 365 tenant. Users will be prompted to authenticate with a second factor upon logging in to Microsoft 365 services. The second factor is most commonly a text message to a registered mobile phone number where they type in an authorization code, or with a mobile application like Microsoft Authenticator. Note: Since 2024, Microsoft has been rolling out mandatory multifactor authentication.", + "Checks": [ + "entra_users_mfa_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Enable multifactor authentication for all users in the Microsoft 365 tenant. Users will be prompted to authenticate with a second factor upon logging in to Microsoft 365 services. The second factor is most commonly a text message to a registered mobile phone number where they type in an authorization code, or with a mobile application like Microsoft Authenticator. Note: Since 2024, Microsoft has been rolling out mandatory multifactor authentication.", + "RationaleStatement": "Multifactor authentication requires an individual to present a minimum of two separate forms of authentication before access is granted. Multifactor authentication provides additional assurance that the individual attempting to gain access is who they claim to be. With multifactor authentication, an attacker would need to compromise at least two different authentication mechanisms, increasing the difficulty of compromise and thus reducing the risk. Note: To ensure that accounts cannot be easily used to enumerate resources (reconnaissance) through Microsoft Admin Portals or through Microsoft Azure Service Management API, both MFA conditional access policies must target All Resources: - \"Ensure multifactor authentication is enabled for all users in administrative roles\" and - \"Ensure multifactor authentication is enabled for all users\" (this recommendation)", + "ImpactStatement": "Implementation of multifactor authentication for all users will necessitate a change to user routine. All users will be required to enroll in multifactor authentication using phone, SMS, or an authentication application. After enrollment, use of multifactor authentication will be required for future authentication to the environment. External identities that attempt to access documents that utilize Purview Information Protection (Sensitivity Labels) will find their access disrupted. In order to mitigate this create an exclusion for Microsoft Rights Management Services ID: 00000012- 0000-0000-c000-000000000000 Note: Organizations that struggle to enforce MFA globally due to budget constraints preventing the provision of company-owned mobile devices to every user, or due to regulations, unions, or policies that prevent forcing end users to use their personal devices, have another option. FIDO2 security keys can be used as an alternative. They are more secure, phishing-resistant, and affordable for organizations to issue to every end user.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Click New policy. o Under Users or agents (Preview) include All users. o Under Target resources include All resources (formerly 'All cloud apps') and do not create any exclusions. - Under Exclude exclude any break-glass accounts. o Under Grant select Grant Access and check either Require multifactor authentication or Require authentication strength. o Click Select at the bottom of the pane. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify All resources (formerly 'All cloud apps') is selected with no exclusions. o Under Grant verify Grant Access and either Require multifactor authentication or Require authentication strength is checked. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeApplications is All o GrantControls.BuiltInControls contains mfa OR GrantControls.AuthenticationStrength.id is defined o State is enabled 3. Compliance is met when at least one policy is found to meet all the criteria listed above. 4. Verify that any exclusions are documented and reviewed annually. Note: The Authentication Strength requirement is satisfied when any valid GUID is present in the authenticationStrength property of a matching policy. Because Authentication Strength configurations are inherently stronger than the built-in Require multifactor authentication control, the presence of a valid Authentication Strength also fulfills the MFA requirement for all users.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default. It may be present as a Microsoft-managed policy or created from a Conditional Access template.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-all-users-mfa:https://learn.microsoft.com/en-us/graph/api/conditionalaccessroot-list-policies?view=graph-rest-1.0:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-mandatory-multifactor-authentication" + } + ] + }, + { + "Id": "5.2.2.3", + "Description": "Entra ID supports the most widely used authentication and authorization protocols including legacy authentication. This authentication pattern includes basic authentication, a widely used industry-standard method for collecting username and password information. The following messaging protocols support legacy authentication: - Authenticated SMTP - Used to send authenticated email messages. - Autodiscover - Used by Outlook and EAS clients to find and connect to mailboxes in Exchange Online. - Exchange ActiveSync (EAS) - Used to connect to mailboxes in Exchange Online. - Exchange Online PowerShell - Used to connect to Exchange Online with remote PowerShell. If you block Basic authentication for Exchange Online PowerShell, you need to use the Exchange Online PowerShell Module to connect. For instructions, see Connect to Exchange Online PowerShell using multifactor authentication. - Exchange Web Services (EWS) - A programming interface that's used by Outlook, Outlook for Mac, and third-party apps. - IMAP4 - Used by IMAP email clients. - MAPI over HTTP (MAPI/HTTP) - Primary mailbox access protocol used by Outlook 2010 SP2 and later. - Offline Address Book (OAB) - A copy of address list collections that are downloaded and used by Outlook. - Outlook Anywhere (RPC over HTTP) - Legacy mailbox access protocol supported by all current Outlook versions. - POP3 - Used by POP email clients. - Reporting Web Services - Used to retrieve report data in Exchange Online. - Universal Outlook - Used by the Mail and Calendar app for Windows 10. - Other clients - Other protocols identified as utilizing legacy authentication.", + "Checks": [ + "entra_legacy_authentication_blocked" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Entra ID supports the most widely used authentication and authorization protocols including legacy authentication. This authentication pattern includes basic authentication, a widely used industry-standard method for collecting username and password information. The following messaging protocols support legacy authentication: - Authenticated SMTP - Used to send authenticated email messages. - Autodiscover - Used by Outlook and EAS clients to find and connect to mailboxes in Exchange Online. - Exchange ActiveSync (EAS) - Used to connect to mailboxes in Exchange Online. - Exchange Online PowerShell - Used to connect to Exchange Online with remote PowerShell. If you block Basic authentication for Exchange Online PowerShell, you need to use the Exchange Online PowerShell Module to connect. For instructions, see Connect to Exchange Online PowerShell using multifactor authentication. - Exchange Web Services (EWS) - A programming interface that's used by Outlook, Outlook for Mac, and third-party apps. - IMAP4 - Used by IMAP email clients. - MAPI over HTTP (MAPI/HTTP) - Primary mailbox access protocol used by Outlook 2010 SP2 and later. - Offline Address Book (OAB) - A copy of address list collections that are downloaded and used by Outlook. - Outlook Anywhere (RPC over HTTP) - Legacy mailbox access protocol supported by all current Outlook versions. - POP3 - Used by POP email clients. - Reporting Web Services - Used to retrieve report data in Exchange Online. - Universal Outlook - Used by the Mail and Calendar app for Windows 10. - Other clients - Other protocols identified as utilizing legacy authentication.", + "RationaleStatement": "Legacy authentication protocols do not support multi-factor authentication. These protocols are often used by attackers because of this deficiency. Blocking legacy authentication makes it harder for attackers to gain access. Note: Basic authentication is now disabled in all tenants. Before December 31 2022, you could re-enable the affected protocols if users and apps in your tenant couldn't connect. Now no one (you or Microsoft support) can re-enable Basic authentication in your tenant.", + "ImpactStatement": "Enabling this setting will block legacy authentication, preventing access from older versions of Microsoft Office, Exchange ActiveSync, and protocols such as IMAP, POP, and SMTP. As a result, some users may need to upgrade to newer Office versions or use email clients that support modern authentication. This change may also affect multifunction devices (MFPs), such as printers using legacy authentication for scan-to-email. Microsoft provides mail flow best practices (linked below) to configure MFPs without relying on legacy authentication. https://learn.microsoft.com/en-us/exchange/mail-flow-best-practices/how-to-set-up-a- multifunction-device-or-application-to-send-email-using-microsoft-365-or-office-365", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) include All users. o Under Target resources include All resources (formerly 'All cloud apps'). - Under Exclude exclude any break-glass accounts. o Under Conditions select Client apps and check the boxes for Exchange ActiveSync clients and Other clients. o Under Grant select Block Access. o Click Select. 4. Set the policy On and click Create.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify All resources (formerly 'All cloud apps') is selected. o Verify that only documented resource exclusions exist and that they are reviewed annually. o Under Conditions select Client apps then verify Exchange ActiveSync clients and Other clients is checked. o Under Grant verify Block access is selected. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where Conditions.ClientAppTypes contains exchangeActiveSync OR other. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeApplications is All o Conditions.ClientAppTypes contains exchangeActiveSync o Conditions.ClientAppTypes contains other o GrantControls.BuiltInControls is block o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually.", + "AdditionalInformation": "", + "DefaultValue": "Basic authentication is disabled by default as of January 2023.", + "References": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/disable-basic-authentication-in-exchange-online:https://learn.microsoft.com/en-us/exchange/mail-flow-best-practices/how-to-set-up-a-multifunction-device-or-application-to-send-email-using-microsoft-365-or-office-365:https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/deprecation-of-basic-authentication-exchange-online" + } + ] + }, + { + "Id": "5.2.2.4", + "Description": "In complex deployments, organizations might have a need to restrict authentication sessions. Conditional Access policies allow for the targeting of specific user accounts. Some scenarios might include: - Resource access from an unmanaged or shared device - Access to sensitive information from an external network - High-privileged users - Business-critical applications Note: This CA policy can be added to the previous CA policy in this benchmark \"Ensure multifactor authentication is enabled for all users in administrative roles\"", + "Checks": [ + "entra_admin_users_sign_in_frequency_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "In complex deployments, organizations might have a need to restrict authentication sessions. Conditional Access policies allow for the targeting of specific user accounts. Some scenarios might include: - Resource access from an unmanaged or shared device - Access to sensitive information from an external network - High-privileged users - Business-critical applications Note: This CA policy can be added to the previous CA policy in this benchmark \"Ensure multifactor authentication is enabled for all users in administrative roles\"", + "RationaleStatement": "Forcing a time out for MFA will help ensure that sessions are not kept alive for an indefinite period of time, ensuring that browser sessions are not persistent will help in prevention of drive-by attacks in web browsers, this also prevents creation and saving of session cookies leaving nothing for an attacker to take.", + "ImpactStatement": "Users with Administrative roles will be prompted at the frequency set for MFA.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Conditional Access and select Policies. 3. Click New policy. o Under Users or agents (Preview) include Select users and groups and check Directory roles. o At a minimum, include the directory roles listed below in this section of the document. o Under Target resources include All resources (formerly 'All cloud apps'). - Under Exclude exclude any break-glass accounts. o Under Grant select Grant Access and check Require multifactor authentication. o Under Session select Sign-in frequency select Periodic reauthentication and set it to 4 hours (or less). o Check Persistent browser session then select Never persistent in the drop-down menu. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On. At minimum these directory roles should be included in the policy: - Application administrator - Authentication administrator - Billing administrator - Cloud application administrator - Conditional Access administrator - Exchange administrator - Global administrator - Global reader - Helpdesk administrator - Password administrator - Privileged authentication administrator - Privileged role administrator - Security administrator - SharePoint administrator - User administrator", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify Directory roles specific to administrators are included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify All resources (formerly 'All cloud apps') is selected. o Verify that only documented resource exclusions exist and that they are reviewed annually. o Under Session verify Sign-in frequency is checked and set to Periodic reauthentication. o Verify the timeframe is set to the time determined by the organization. o Verify that Periodic reauthentication does not exceed 4 hours (or less). o Verify that Persistent browser session is set to Never persistent. 4. Verify that Enable policy is set to On To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where SessionControls.PersistentBrowser.IsEnabled is true. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeRoles contains the directory roles specific to administrators* o Conditions.Applications.IncludeApplications is All o SessionControls.SignInFrequency.IsEnabled is true o SessionControls.SignInFrequency.FrequencyInterval is everyTime OR is timeBased AND does not exceed 4 hours. o SessionControls.PersistentBrowser.IsEnabled is true o SessionControls.PersistentBrowser.Mode is never o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually. Note: A list of directory roles applying to Administrators can be found in the remediation section.", + "AdditionalInformation": "", + "DefaultValue": "The default configuration for user sign-in frequency is a rolling window of 90 days.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-session-lifetime" + } + ] + }, + { + "Id": "5.2.2.5", + "Description": "Authentication strength is a Conditional Access control that allows administrators to specify which combination of authentication methods can be used to access a resource. For example, they can make only phishing-resistant authentication methods available to access a sensitive resource. But to access a non-sensitive resource, they can allow less secure multifactor authentication (MFA) combinations, such as password + SMS. Microsoft has 3 built-in authentication strengths. MFA strength, Passwordless MFA strength, and Phishing-resistant MFA strength. Ensure administrator roles are using a CA policy with Phishing-resistant MFA strength. Administrators can then enroll using one of 3 methods: - FIDO2 Security Key - Windows Hello for Business - Certificate-based authentication (Multi-Factor) Note: Additional steps to configure methods such as FIDO2 keys are not covered here but can be found in related MS articles in the references section. The Conditional Access policy only ensures 1 of the 3 methods is used. Warning: Administrators should be pre-registered for a strong authentication mechanism before this Conditional Access Policy is enforced. Additionally, as stated elsewhere in the CIS Benchmark a break-glass administrator account should be excluded from this policy to ensure unfettered access in the case of an emergency.", + "Checks": [ + "entra_admin_users_phishing_resistant_mfa_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Authentication strength is a Conditional Access control that allows administrators to specify which combination of authentication methods can be used to access a resource. For example, they can make only phishing-resistant authentication methods available to access a sensitive resource. But to access a non-sensitive resource, they can allow less secure multifactor authentication (MFA) combinations, such as password + SMS. Microsoft has 3 built-in authentication strengths. MFA strength, Passwordless MFA strength, and Phishing-resistant MFA strength. Ensure administrator roles are using a CA policy with Phishing-resistant MFA strength. Administrators can then enroll using one of 3 methods: - FIDO2 Security Key - Windows Hello for Business - Certificate-based authentication (Multi-Factor) Note: Additional steps to configure methods such as FIDO2 keys are not covered here but can be found in related MS articles in the references section. The Conditional Access policy only ensures 1 of the 3 methods is used. Warning: Administrators should be pre-registered for a strong authentication mechanism before this Conditional Access Policy is enforced. Additionally, as stated elsewhere in the CIS Benchmark a break-glass administrator account should be excluded from this policy to ensure unfettered access in the case of an emergency.", + "RationaleStatement": "Sophisticated attacks targeting MFA are more prevalent as the use of it becomes more widespread. These 3 methods are considered phishing-resistant as they remove passwords from the login workflow. It also ensures that public/private key exchange can only happen between the devices and a registered provider which prevents login to fake or phishing websites.", + "ImpactStatement": "If administrators aren't pre-registered for a strong authentication method prior to a conditional access policy being created, then a condition could occur where a user can't register for strong authentication because they don't meet the conditional access policy requirements and therefore are prevented from signing in. Additionally, Internet Explorer based credential prompts in PowerShell do not support prompting for a security key. Implementing phishing-resistant MFA with a security key may prevent admins from running their existing sets of PowerShell scripts. Device Authorization Grant Flow can be used as a workaround in some instances.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Click New policy. o Under Users or agents (Preview) include Select users and groups and check Directory roles. o At a minimum, include the directory roles listed below in this section of the document. o Under Target resources include All resources (formerly 'All cloud apps') and do not create any exclusions other than break-glass accounts. o Under Grant select Grant Access and check Require authentication strength and set Phishing-resistant MFA in the dropdown box. o Click Select. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On. Warning: Ensure administrators are pre-registered with strong authentication before enforcing the policy. After which the policy must be set to On. At minimum these directory roles should be included for the policy: - Application administrator - Authentication administrator - Billing administrator - Cloud application administrator - Conditional Access administrator - Exchange administrator - Global administrator - Global reader - Helpdesk administrator - Password administrator - Privileged authentication administrator - Privileged role administrator - Security administrator - SharePoint administrator - User administrator", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify Directory roles specific to administrators are included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Directory Roles should include at minimum the roles listed in the remediation section. o Under Target resources verify All resources (formerly 'All cloud apps') is selected with no exclusions. o Under Grant verify Grant Access is selected and Require authentication strength is checked with Phishing-resistant MFA set as the value. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where GrantControls.AuthenticationStrength.Id contains any valid id. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeRoles contains the directory roles specific to administrators* o Conditions.Applications.IncludeApplications is All o GrantControls.AuthenticationStrength.AllowedCombinations only contains windowsHelloForBusiness OR fido2 OR x509CertificateMultiFactor o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default. It can be created from a Conditional Access template.", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-passwordless#fido2-security-keys:https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-enable-passkey-fido2:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-strengths:https://learn.microsoft.com/en-us/entra/id-protection/howto-identity-protection-configure-mfa-policy" + } + ] + }, + { + "Id": "5.2.2.6", + "Description": "Microsoft Entra ID Protection user risk policies detect the probability that a user account has been compromised. Note: While Identity Protection also provides two risk policies with limited conditions, Microsoft highly recommends setting up risk-based policies in Conditional Access as opposed to the \"legacy method\" for the following benefits: - Enhanced diagnostic data - Report-only mode integration - Graph API support - Use more Conditional Access attributes like sign-in frequency in the policy", + "Checks": [ + "entra_identity_protection_user_risk_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra ID Protection user risk policies detect the probability that a user account has been compromised. Note: While Identity Protection also provides two risk policies with limited conditions, Microsoft highly recommends setting up risk-based policies in Conditional Access as opposed to the \"legacy method\" for the following benefits: - Enhanced diagnostic data - Report-only mode integration - Graph API support - Use more Conditional Access attributes like sign-in frequency in the policy", + "RationaleStatement": "With the user risk policy turned on, Entra ID protection detects the probability that a user account has been compromised. Administrators can configure a user risk conditional access policy to automatically respond to a specific user risk level.", + "ImpactStatement": "Upon policy activation, account access will be either blocked or the user will be required to use multi-factor authentication (MFA) and change their password. Users without registered MFA will be denied access, necessitating an admin to recover the account. To avoid inconvenience, it is advised to configure the MFA registration policy for all users under the User Risk policy. Additionally, users identified in the Risky Users section will be affected by this policy. To gain a better understanding of the impact on the organization's environment, the list of Risky Users should be reviewed before enforcing the policy.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) choose All users o Under Target resources choose All resources (formerly 'All cloud apps') - Under Exclude exclude any break-glass accounts. o Under Conditions choose User risk then Yes and select the user risk level High. o Under Grant select Grant access then check Require multifactor authentication or Require authentication strength. Finally check Require password change. o Under Session set Sign-in frequency to Every time. o Click Select. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify All resources (formerly 'All cloud apps') is selected. o Under Conditions verify User risk is set to High. o Under Grant verify Grant access is selected and either Require multifactor authentication or Require authentication strength are checked. Then verify Require password change is checked. o Under Session ensure Sign-in frequency is set to Every time. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where Conditions.UserRiskLevels contains any string. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeApplications is All o Conditions.UserRiskLevels contains high o GrantControls.BuiltInControls contains passwordChange o GrantControls.BuiltInControls contains mfa OR GrantControls.AuthenticationStrength.id is any id o SessionControls.SignInFrequency.IsEnabled is true o SessionControls.SignInFrequency.FrequencyInterval is everyTime o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually. Note: If the UserRiskLevels array includes medium or low, this still qualifies as compliant, as these enforcement levels are considered more strict. However, it must always include high; omitting this level would exclude users who are classified as high risk.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default. It may be created from a Conditional Access template.", + "References": "https://learn.microsoft.com/en-us/entra/id-protection/howto-identity-protection-risk-feedback:https://learn.microsoft.com/en-us/entra/id-protection/concept-identity-protection-risks" + } + ] + }, + { + "Id": "5.2.2.7", + "Description": "Microsoft Entra ID Protection sign-in risk detects risks in real-time and offline. A risky sign-in is an indicator for a sign-in attempt that might not have been performed by the legitimate owner of a user account. Note: While Identity Protection also provides two risk policies with limited conditions, Microsoft highly recommends setting up risk-based policies in Conditional Access as opposed to the \"legacy method\" for the following benefits: - Enhanced diagnostic data - Report-only mode integration - Graph API support - Use more Conditional Access attributes like sign-in frequency in the policy", + "Checks": [ + "entra_identity_protection_sign_in_risk_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra ID Protection sign-in risk detects risks in real-time and offline. A risky sign-in is an indicator for a sign-in attempt that might not have been performed by the legitimate owner of a user account. Note: While Identity Protection also provides two risk policies with limited conditions, Microsoft highly recommends setting up risk-based policies in Conditional Access as opposed to the \"legacy method\" for the following benefits: - Enhanced diagnostic data - Report-only mode integration - Graph API support - Use more Conditional Access attributes like sign-in frequency in the policy", + "RationaleStatement": "Turning on the sign-in risk policy ensures that suspicious sign-ins are challenged for multi-factor authentication.", + "ImpactStatement": "When the policy triggers, the user will need MFA to access the account. In the case of a user who hasn't registered MFA on their account, they would be blocked from accessing their account. It is therefore recommended that the MFA registration policy be configured for all users who are a part of the Sign-in Risk policy.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) choose All users. o Under Target resources choose All resources (formerly 'All cloud apps'). - Under Exclude exclude any break-glass accounts. o Under Conditions choose Sign-in risk then Yes and check the risk level boxes High and Medium. o Under Grant click Grant access then select Require multifactor authentication. o Under Session select Sign-in Frequency and set to Every time. o Click Select. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify All resources (formerly 'All cloud apps') is selected. o Under Conditions verify Sign-in risk is set to Yes ensuring High and Medium are selected. o Under Grant verify grant Grant access is selected and Require multifactor authentication checked. o Under Session verify Sign-in Frequency is set to Every time. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where Conditions.SignInRiskLevels contains any string. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeApplications is All o Conditions.SignInRiskLevels contains high o Conditions.SignInRiskLevels contains medium o GrantControls.BuiltInControls contains mfa OR GrantControls.AuthenticationStrength.id is any id o SessionControls.SignInFrequency.IsEnabled is true o SessionControls.SignInFrequency.FrequencyInterval is everyTime o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually. Note: If the SignInRiskLevels array includes low, this still qualifies as compliant, as these enforcement levels are considered more strict. However, it must always include high and medium; omitting these levels would exclude users who are classified as such. Note 2: If GrantControls.BuiltInControls is block then the Grant and Session controls are considered satisfied, as this is considered a more strict enforcement of sign-in risk control.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default. It may be created from a Conditional Access template.", + "References": "https://learn.microsoft.com/en-us/entra/id-protection/howto-identity-protection-risk-feedback:https://learn.microsoft.com/en-us/entra/id-protection/concept-identity-protection-risks" + } + ] + }, + { + "Id": "5.2.2.8", + "Description": "Microsoft Entra ID Protection sign-in risk detects risks in real-time and offline. A risky sign-in is an indicator for a sign-in attempt that might not have been performed by the legitimate owner of a user account. Note: While Identity Protection also provides two risk policies with limited conditions, Microsoft highly recommends setting up risk-based policies in Conditional Access as opposed to the \"legacy method\" for the following benefits: - Enhanced diagnostic data - Report-only mode integration - Graph API support - Use more Conditional Access attributes like sign-in frequency in the policy", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E5 Level 2", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra ID Protection sign-in risk detects risks in real-time and offline. A risky sign-in is an indicator for a sign-in attempt that might not have been performed by the legitimate owner of a user account. Note: While Identity Protection also provides two risk policies with limited conditions, Microsoft highly recommends setting up risk-based policies in Conditional Access as opposed to the \"legacy method\" for the following benefits: - Enhanced diagnostic data - Report-only mode integration - Graph API support - Use more Conditional Access attributes like sign-in frequency in the policy", + "RationaleStatement": "Sign-in risk is determined at the time of sign-in and includes criteria across both real- time and offline detections for risk. Blocking sign-in to accounts that have risk can prevent undesired access from potentially compromised devices or unauthorized users.", + "ImpactStatement": "Sign-in risk is heavily dependent on detecting risk based on atypical behaviors. Due to this it is important to run this policy in a report-only mode to better understand how the organization's environment and user activity may influence sign-in risk before turning the policy on. Once it's understood what actions may trigger a medium or high sign-in risk event I.T. can then work to create an environment to reduce false positives. For example, employees might be required to notify security personnel when they intend to travel with intent to access work resources.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) include All users. o Under Target resources include All resources (formerly 'All cloud apps') and do not set any exclusions. - Under Exclude exclude any break-glass accounts. o Under Conditions choose Sign-in risk values of High and Medium and click Done. o Under Grant choose Block access and click Select. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify All resources (formerly 'All cloud apps') is selected with no exclusions. o Under Conditions verify Sign-in risk values of High and Medium are selected. o Under Grant verify Block access is selected. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where Conditions.SignInRiskLevels contains any string. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeApplications is All o Conditions.Applications.ExcludeApplications is null or empty o Conditions.SignInRiskLevels contains high o Conditions.SignInRiskLevels contains medium o GrantControls.BuiltInControls is block o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually. Note: If the SignInRiskLevels array includes low, this still qualifies as compliant, as these enforcement levels are considered more strict. However, it must always include high and medium; omitting these levels would exclude users who are classified as such.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default.", + "References": "https://learn.microsoft.com/en-us/entra/id-protection/concept-identity-protection-risks#risk-detections-mapped-to-riskeventtype" + } + ] + }, + { + "Id": "5.2.2.9", + "Description": "Conditional Access (CA) can be configured to enforce access based on the device's compliance status or whether it is Entra hybrid joined. Collectively this allows CA to classify devices as managed or unmanaged, providing more granular control over authentication policies. When using Require device to be marked as compliant, the device must pass checks configured in compliance policies defined within Microsoft Intune. Before these checks can be applied, the device must first be enrolled in Intune MDM. By selecting Require Microsoft Entra hybrid joined device this means the device must first be synchronized from an on-premises Active Directory to qualify for authentication. When configured to the recommended state below only one condition needs to be met for the user to authenticate from the device. This functions as an \"OR\" operator. The recommended state for authentication is: - Require device to be marked as compliant - Require Microsoft Entra hybrid joined device (optional for hybrid environments) - Require one of the selected controls", + "Checks": [ + "entra_managed_device_required_for_authentication" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Conditional Access (CA) can be configured to enforce access based on the device's compliance status or whether it is Entra hybrid joined. Collectively this allows CA to classify devices as managed or unmanaged, providing more granular control over authentication policies. When using Require device to be marked as compliant, the device must pass checks configured in compliance policies defined within Microsoft Intune. Before these checks can be applied, the device must first be enrolled in Intune MDM. By selecting Require Microsoft Entra hybrid joined device this means the device must first be synchronized from an on-premises Active Directory to qualify for authentication. When configured to the recommended state below only one condition needs to be met for the user to authenticate from the device. This functions as an \"OR\" operator. The recommended state for authentication is: - Require device to be marked as compliant - Require Microsoft Entra hybrid joined device (optional for hybrid environments) - Require one of the selected controls", + "RationaleStatement": "\"Managed\" devices are considered more secure because they often have additional configuration hardening enforced through centralized management such as Intune or Group Policy. These devices are also typically equipped with MDR/EDR, managed patching and alerting systems. As a result, they provide a safer environment for users to authenticate and operate from. This policy also ensures that attackers must first gain access to a compliant or trusted device before authentication is permitted, reducing the risk posed by compromised account credentials. When combined with other distinct Conditional Access (CA) policies, such as requiring multi-factor authentication, this adds one additional factor before authentication is permitted. Note: Avoid combining these two settings with other Grant settings in the same policy. In a single policy you can only choose between Require all the selected controls or Require one of the selected controls, which limits the ability to integrate this recommendation with others in this benchmark. CA policies function as an \"AND\" operator across multiple policies. The goal here is to both (Require MFA for all users) AND (Require device to be marked as compliant OR Require Microsoft Entra hybrid joined device).", + "ImpactStatement": "Unmanaged devices will not be permitted as a valid authenticator. As a result this may require the organization to mature their device enrollment and management. The following devices can be considered managed: - Entra hybrid joined from Active Directory - Entra joined and enrolled in Intune, with compliance policies - Entra registered and enrolled in Intune, with compliance policies If Guest or external users are collaborating with the organization, they must either be excluded or onboarded with a compliant device to authenticate. Failure to adequately survey the environment and test the Conditional Access (CA) policy in the Report-only state could result in access disruptions for these guest users.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) include All users. o Under Target resources include All resources (formerly 'All cloud apps'). - Under Exclude exclude any break-glass accounts. o Under Grant select Grant access. o Select the checkbox Require device to be marked as compliant. o Optionally, also select Require Microsoft Entra hybrid joined device if the organization uses hybrid joined devices. o Choose Require one of the selected controls. o Click Select at the bottom. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On. Note: Guest user accounts, if collaborating with the organization, should be considered when testing this policy.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify All resources (formerly 'All cloud apps') is selected. o Verify that only documented resource exclusions exist and that they are reviewed annually. o Under Grant verify that Require device to be marked as compliant is checked. o Under Grant verify that no other controls are checked except, optionally, Require Microsoft Entra hybrid joined device. o Verify Require one of the selected controls is selected. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where GrantControls.BuiltInControls contains compliantDevice or domainJoinedDevice. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeApplications is All o GrantControls.BuiltInControls contains compliantDevice o GrantControls.BuiltInControls does not contain any values other than compliantDevice and domainJoinedDevice o GrantControls.Operator is OR o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-grant#require-device-to-be-marked-as-compliant:https://learn.microsoft.com/en-us/entra/identity/devices/concept-hybrid-join:https://learn.microsoft.com/en-us/mem/intune/fundamentals/deployment-guide-enrollment" + } + ] + }, + { + "Id": "5.2.2.10", + "Description": "Conditional Access (CA) can be configured to enforce access based on the device's compliance status or whether it is Entra hybrid joined. Collectively, this allows CA to classify devices as managed or unmanaged, providing more granular control over whether a user can register security information from a device. When using Require device to be marked as compliant, the device must pass checks configured in compliance policies defined within Microsoft Intune. Before these checks can be applied, the device must first be enrolled in Intune MDM. By selecting Require Microsoft Entra hybrid joined device this means the device must first be synchronized from an on-premises Active Directory to qualify. When configured to the recommended state below only one condition needs to be met for the user to authenticate from the device. This functions as an \"OR\" operator. The recommended state for registering security information is: - Require device to be marked as compliant - Require Microsoft Entra hybrid joined device (optional for hybrid environments) - Require one of the selected controls", + "Checks": [ + "entra_managed_device_required_for_mfa_registration" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Conditional Access (CA) can be configured to enforce access based on the device's compliance status or whether it is Entra hybrid joined. Collectively, this allows CA to classify devices as managed or unmanaged, providing more granular control over whether a user can register security information from a device. When using Require device to be marked as compliant, the device must pass checks configured in compliance policies defined within Microsoft Intune. Before these checks can be applied, the device must first be enrolled in Intune MDM. By selecting Require Microsoft Entra hybrid joined device this means the device must first be synchronized from an on-premises Active Directory to qualify. When configured to the recommended state below only one condition needs to be met for the user to authenticate from the device. This functions as an \"OR\" operator. The recommended state for registering security information is: - Require device to be marked as compliant - Require Microsoft Entra hybrid joined device (optional for hybrid environments) - Require one of the selected controls", + "RationaleStatement": "Requiring registration on a managed device significantly reduces the risk of bad actors using stolen credentials to register security information. Accounts that are created but never registered with an MFA method are particularly vulnerable to this type of attack. Enforcing this requirement will both reduce the attack surface for fake registrations and ensure that legitimate users register using trusted devices which typically have additional security measures in place already.", + "ImpactStatement": "The organization will be required to have a mature device management process. New devices provided to users will need to be pre-enrolled in Intune, auto-enrolled, or be Entra hybrid joined. Otherwise, the user will be unable to complete registration, requiring additional resources from I.T. This could be more disruptive in remote worker environments where the MDM maturity is low. Users who do not yet have access to a managed device, such as new hires, users with lost or replaced devices, or users registering MFA methods on personal mobile devices - will be unable to satisfy the device compliance grant control. To address this, organizations should configure a Temporary Access Pass (TAP) policy and issue a one- time TAP to these users. A one-time TAP satisfies multifactor authentication requirements and allows the user to register security information from any device or location. B2B collaboration users (guest accounts) will also be blocked by this policy, as their devices are not managed in the resource tenant. Organizations should consider excluding All guest and external users from this policy. Alternatively, organizations that trust partner device compliance claims can configure this through cross-tenant access settings.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) include All users. o Under Target resources select User actions and check Register security information. - Under Exclude exclude any break-glass accounts. o Under Grant select Grant access. o Select the checkbox Require device to be marked as compliant. o Optionally, also select Require Microsoft Entra hybrid joined device if the organization uses hybrid joined devices. o Choose Require one of the selected controls. o Click Select at the bottom. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify User actions is selected with Register security information checked. o Under Grant verify that Require device to be marked as compliant is checked. o Under Grant verify that no other controls are checked except, optionally, Require Microsoft Entra hybrid joined device. o Verify Require one of the selected controls is selected. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where Conditions.Applications.IncludeUserActions contains urn:user:registersecurityinfo. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeUserActions is urn:user:registersecurityinfo o GrantControls.BuiltInControls contains compliantDevice o GrantControls.BuiltInControls does not contain any values other than compliantDevice and domainJoinedDevice o GrantControls.Operator is OR o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-grant#require-device-to-be-marked-as-compliant:https://learn.microsoft.com/en-us/entra/identity/devices/concept-hybrid-join:https://learn.microsoft.com/en-us/mem/intune/fundamentals/deployment-guide-enrollment:https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-cloud-apps#user-actions:https://docs.azure.cn/en-us/entra/identity/authentication/concept-authentication-strength-how-it-works#how-multiple-authentication-strength-policies-are-evaluated-for-registering-security-info" + } + ] + }, + { + "Id": "5.2.2.11", + "Description": "Sign-in frequency defines the time period before a user is asked to sign in again when attempting to access a resource. The Microsoft Entra ID default configuration for user sign-in frequency is a rolling window of 90 days. The recommended state is a Sign-in frequency of Every time for Microsoft Intune Enrollment Note: Microsoft accounts for a five-minute clock skew when 'every time' is selected in a conditional access policy, ensuring that users are not prompted more frequently than once every five minutes.", + "Checks": [ + "entra_intune_enrollment_sign_in_frequency_every_time" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Sign-in frequency defines the time period before a user is asked to sign in again when attempting to access a resource. The Microsoft Entra ID default configuration for user sign-in frequency is a rolling window of 90 days. The recommended state is a Sign-in frequency of Every time for Microsoft Intune Enrollment Note: Microsoft accounts for a five-minute clock skew when 'every time' is selected in a conditional access policy, ensuring that users are not prompted more frequently than once every five minutes.", + "RationaleStatement": "Intune Enrollment is considered a sensitive action and should be safeguarded. An attack path exists that allows for a bypass of device compliance Conditional Access rule. This could allow compromised credentials to be used through a newly registered device enrolled in Intune, enabling persistence and privilege escalation. Setting sign-in frequency to every time limits the timespan an attacker could use fresh credentials to enroll a new device to Intune.", + "ImpactStatement": "New users enrolling into Intune through an automated process may need to sign-in again if the enrollment process goes on for too long.", + "RemediationProcedure": "Note: If the Microsoft Intune Enrollment cloud app isn't available then it must be created. To add the app for new tenants, a Microsoft Entra administrator must create a service principal object, with app ID d4ebce55-015a-49b5-a083-c84d1797ae8c, in PowerShell or Microsoft Graph. To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) include All users. o Under Target resources select Resources (formerly cloud apps), choose Select resources and add Microsoft Intune Enrollment to the list. - Under Exclude exclude any break-glass accounts. o Under Grant select Grant access. o Check either Require multifactor authentication or Require authentication strength. o Under Session check Sign-in frequency and select Every time. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify Resources (formerly cloud apps) includes Microsoft Intune Enrollment. o Under Grant verify Require multifactor authentication or Require authentication strength is checked. o Under Session verify Sign-in frequency is set to Every time. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: Note: The Authentication Strength requirement is satisfied when any valid GUID is present in the authenticationStrength property of a matching policy. Because Authentication Strength configurations are inherently stronger than the built-in Require multifactor authentication control, the presence of a valid Authentication Strength also fulfills the MFA requirement for all users. 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where Conditions.Applications.IncludeApplications contains d4ebce55-015a-49b5-a083-c84d1797ae8c. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeApplications contains d4ebce55- 015a-49b5-a083-c84d1797ae8c o GrantControls.BuiltInControls contains mfa OR GrantControls.AuthenticationStrength.id is defined o SessionControls.SignInFrequency.IsEnabled is true o SessionControls.SignInFrequency.FrequencyInterval is everyTime o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default. Sign-in frequency defaults to 90 days.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-session-lifetime#require-reauthentication-every-time:https://www.blackhat.com/eu-24/briefings/schedule/#unveiling-the-power-of-intune-leveraging-intune-for-breaking-into-your-cloud-and-on-premise-42176:https://www.glueckkanja.com/blog/security/2025/01/compliant-device-bypass-en/" + } + ] + }, + { + "Id": "5.2.2.12", + "Description": "The Microsoft identity platform supports the device authorization grant, which allows users to sign in to input-constrained devices such as a smart TV, IoT device, or a printer. To enable this flow, the device has the user visit a webpage in a browser on another device to sign in. Once the user signs in, the device is able to get access tokens and refresh tokens as needed. The recommended state is to Block access for Device code flow in Conditional Access.", + "Checks": [ + "entra_conditional_access_policy_device_code_flow_blocked" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "The Microsoft identity platform supports the device authorization grant, which allows users to sign in to input-constrained devices such as a smart TV, IoT device, or a printer. To enable this flow, the device has the user visit a webpage in a browser on another device to sign in. Once the user signs in, the device is able to get access tokens and refresh tokens as needed. The recommended state is to Block access for Device code flow in Conditional Access.", + "RationaleStatement": "Since August 2024, Microsoft has observed threat actors, such as Storm-2372, employing \"device code phishing\" attacks. These attacks deceive users into logging into productivity applications, capturing authentication tokens to gain further access to compromised accounts. To mitigate this specific attack, block authentication code flows and permit only those from devices within trusted environments, identified by specific IP addresses.", + "ImpactStatement": "Some administrative overhead will be required for stricter management of these devices. Since exclusions do not violate compliance, this feature can still be utilized effectively within a controlled environment.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) include All users. o Under Target resources > Resources (formerly cloud apps) include All resources (formerly 'All cloud apps'). - Under Exclude exclude any break-glass accounts. o Under Conditions > Authentication flows set Configure to Yes and check Device code flow. o Click Save. o Under Grant select Block access and click Select. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to `On", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify Resources (formerly cloud apps) includes All resources (formerly 'All cloud apps') o Under Conditions > Authentication flows verify Configure is set to Yes and Device code flow is checked. o Under Grant verify Block access is selected. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where Conditions.AuthenticationFlows.TransferMethods contains deviceCodeFlow. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeApplications is All o Conditions.AuthenticationFlows.TransferMethods contains deviceCodeFlow o GrantControls.BuiltInControls is block o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default. It may be present as a Microsoft-managed policy.", + "References": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-device-code:https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-authentication-flows:https://www.microsoft.com/en-us/security/blog/2025/02/13/storm-2372-conducts-device-code-phishing-campaign/:https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-block-authentication-flows#device-code-flow-policies" + } + ] + }, + { + "Id": "5.2.2.13", + "Description": "Sign-in frequency defines the time period before a user is asked to sign in again when attempting to access a resource. The Microsoft Entra ID default configuration for user sign-in frequency is a rolling window of 90 days. The recommended state for all users is to enforce periodic reauthentication for 7 days or less.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Sign-in frequency defines the time period before a user is asked to sign in again when attempting to access a resource. The Microsoft Entra ID default configuration for user sign-in frequency is a rolling window of 90 days. The recommended state for all users is to enforce periodic reauthentication for 7 days or less.", + "RationaleStatement": "A 7-day interval balances security and user experience by reducing the maximum lifespan of compromised credentials or stolen tokens without introducing excessive reauthentication prompts that can increase phishing susceptibility and user fatigue.", + "ImpactStatement": "Most users will not find weekly reauthentication requirements disruptive. Organizations with legacy applications, custom authentication workflows, or users relying on long-running sessions (such as shared or kiosk devices) may need to evaluate compatibility and apply appropriate exclusions to prevent user disruption.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) include All users. o Under Target resources verify Resources (formerly cloud apps) include All resources (formerly 'All cloud apps'). - Under Exclude exclude any break-glass accounts. o Under Session select Sign-in frequency and set Periodic reauthentication to 7 days or less. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify Resources (formerly cloud apps) includes All resources (formerly 'All cloud apps') o Under Session ensure Sign-in frequency is checked, and Periodic reauthentication is set to 7 days or less. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where SessionControls.SignInFrequency.IsEnabled is true 3. Filter out policies where Conditions.signInRiskLevels and Conditions.userRiskLevels have values defined, excluding these from the assessment. 4. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeApplications is All o SessionControls.SignInFrequency.frequencyInterval is timeBased o SessionControls.SignInFrequency.type is days* o SessionControls.SignInFrequency.value is 7 (or less)* o State is enabled 5. Compliance is met when at least one policy is found to meet all the criteria listed above. 6. Verify that any exclusions are documented and reviewed annually. Note: Any SignInFrequency type and value combination is considered compliant as long as the reauthentication interval is less than or equal to 7 days. For example, a policy applied to all users that requires reauthentication every 20 hours would still meet the requirement.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-session-lifetime" + } + ] + }, + { + "Id": "5.2.2.14", + "Description": "Microsoft Entra ID Conditional Access allows an organization to configure Named locations and configure whether those locations are trusted or untrusted. These settings provide organizations the means to specify Geographical locations for use in conditional access policies, or define actual IP addresses and IP ranges and whether or not those IP addresses and/or ranges are trusted by the organization. The recommended state is to define at least one trusted, IP range named location.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra ID Conditional Access allows an organization to configure Named locations and configure whether those locations are trusted or untrusted. These settings provide organizations the means to specify Geographical locations for use in conditional access policies, or define actual IP addresses and IP ranges and whether or not those IP addresses and/or ranges are trusted by the organization. The recommended state is to define at least one trusted, IP range named location.", + "RationaleStatement": "Defining trusted source IP addresses or ranges enables organizations to better tailor and enforce Conditional Access policies based on whether authentication attempts originate from trusted or untrusted network locations. Users signing in from trusted IP ranges can be granted reduced access requirements or fewer authentication prompts, while users coming from untrusted or unknown locations may face stricter controls. Additionally, marking named locations as trusted improves the accuracy of Microsoft Entra ID Protection's risk evaluations. When a user authenticates from a trusted location, their sign-in risk is appropriately lowered, helping reduce false positives and ensuring that risk-based policies trigger only when truly necessary.", + "ImpactStatement": "Configuring named locations by country cannot designate those locations as trusted, which means Conditional Access policies cannot use the \"All trusted locations\" option and must instead rely on explicitly selecting locations. This increases the administrative effort needed to configure and maintain these policies and requires more thorough testing to prevent unintended authentication blocks. Because Conditional Access policies can fully prevent users from signing in to Entra ID if misconfigured, organizations should maintain a dedicated break-glass Global Administrator account that is excluded from all Conditional Access policies and secured with a strong passphrase and hardware-based authentication. This account exists solely to recover access if all other administrators are locked out.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Under Manage, click Named locations. 4. Click on IP ranges location to add a new location. 5. Enter a name for this location setting in the Name field. 6. Click on the + icon. 7. Add only a trusted IP Address Range in CIDR notation inside the text box that appears. 8. Click on the Add button. 9. Repeat steps 6 through 8 for each additional IP range. 10. Select the Mark as trusted location checkbox. 11. Once finished, click on Create. Note: There is no single prescribed method for applying a named location to a Conditional Access policy, as the correct configuration depends on the specific access control requirements. Implementers should have a clear understanding of how named locations function before applying them to production policies.", + "AuditProcedure": "To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Under Manage, click Named locations. 4. For each named location with a Location type of IP ranges, verify there is one with the following criteria: o Trusted is set to Yes. o At least one IP Range is defined 5. Compliance is met when at least one named location is found to meet all the criteria listed above. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/namedLocations?$filter=isof('microsoft.graph. ipNamedLocation') 2. For each named location returned, verify the following criteria: o isTrusted is true o ipRanges contains at least one ipv4 or ipv6 range 3. Compliance is met when at least one named location is found to meet all the criteria listed above.", + "AdditionalInformation": "", + "DefaultValue": "Named locations are not configured by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-assignment-network:https://learn.microsoft.com/en-us/entra/id-protection/concept-risk-detection-types#locations:https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0" + } + ] + }, + { + "Id": "5.2.2.15", + "Description": "Conditional Access Policies can be used to block access from geographic locations that are deemed out-of-scope for your organization or application. The scope and variables for this policy should be carefully examined and defined. The recommended state is to configure at least one policy to block access from untrusted locations.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Conditional Access Policies can be used to block access from geographic locations that are deemed out-of-scope for your organization or application. The scope and variables for this policy should be carefully examined and defined. The recommended state is to configure at least one policy to block access from untrusted locations.", + "RationaleStatement": "Using Conditional Access as a deny list at the tenant or subscription level enables an organization to block inbound and outbound traffic from geographic locations that fall outside its operational scope (e.g., customers, suppliers) or legal jurisdiction. Restricting access to only required regions significantly reduces unnecessary exposure to international threat actors, including advanced persistent threats (APTs), and helps maintain a more controlled and defensible security posture. Note: Because the selection of allowed or blocked locations is unique to each organization, this control does not prescribe specific countries or regions. Each organization should determine its geographic access requirements based on operational needs, regulatory obligations, and risk tolerance.", + "ImpactStatement": "Limiting access geographically will deny access to users that are traveling or working remotely in a different part of the world. A point-to-site or site to site tunnel such as a VPN is recommended to address exceptions to geographic access policies. CAUTION: If these policies are created without first auditing and testing the result, misconfiguration can potentially lock out administrators or create undesired access issues.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) include All users o Under Target resources include All resources (formerly 'All cloud apps'). - Under Exclude exclude any break-glass accounts. o Under Network set Configure to Yes: - Select Include, then add entries for untrusted locations that should be blocked - Select Exclude, then add entries for trusted locations that should be allowed 4. Under Access Controls, select Grant select Block Access. 5. Under Enable policy set it to Report-only. 6. Click Create. 7. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria: o Under Users or agents (Preview) verify All users is included o Verify that only documented user exclusions exist and that they are reviewed annually o Under Target resources verify All resources (formerly 'All cloud apps') is selected o Under Network verify Include> Selected networks and locations contains at least one untrusted location o Under Network verify Exclude contains trusted locations through either All trusted networks and locations or Selected networks and locations o Under Grant verify Block access is selected 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. For each policy returned, verify the following criteria: o conditions.users.includeUsers is All o conditions.applications.includeApplications is All o conditions.locations.includeLocations contains at least one GUID of at least one untrusted location. o conditions.locations.excludeLocations is AllTrusted OR contains at least one GUID of at least one trusted location. o grantControls.builtInControls is block o state is enabled 3. Compliance is met when at least one policy is found to meet all the criteria listed above. 4. Verify that any exclusions are documented and reviewed annually.", + "AdditionalInformation": "These policies should be tested by using the What If tool in the References. Setting these can and will create issues with logging in for users until they use an MFA device linked to their accounts. Further testing can also be done via the insights and reporting resource in References which monitors Azure sign ins.", + "DefaultValue": "This policy does not exist by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-block-by-location:https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-report-only" + } + ] + }, + { + "Id": "5.2.2.16", + "Description": "Token Protection is a Conditional Access session control that attempts to reduce token replay attacks by ensuring only device bound sign-in session tokens, like Primary Refresh Tokens (PRTs), are accepted by Microsoft Entra ID when applications request access to protected resources. When a user registers a supported device with Microsoft Entra, a PRT is issued and cryptographically bound to that device. This binding ensures that even if a threat actor steals the token, it can't be used from another device. With Token Protection enforced, Microsoft Entra validates that only these bound sign-in session tokens are used by supported applications. The recommended state is to enforce Token Protection for Office 365 Exchange Online, Office 365 SharePoint Online and Microsoft Teams Services.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Token Protection is a Conditional Access session control that attempts to reduce token replay attacks by ensuring only device bound sign-in session tokens, like Primary Refresh Tokens (PRTs), are accepted by Microsoft Entra ID when applications request access to protected resources. When a user registers a supported device with Microsoft Entra, a PRT is issued and cryptographically bound to that device. This binding ensures that even if a threat actor steals the token, it can't be used from another device. With Token Protection enforced, Microsoft Entra validates that only these bound sign-in session tokens are used by supported applications. The recommended state is to enforce Token Protection for Office 365 Exchange Online, Office 365 SharePoint Online and Microsoft Teams Services.", + "RationaleStatement": "When properly configured, Conditional Access can aid in preventing attacks involving token theft, via hijacking or replay, as part of the attack flow. Although currently considered a rare event, the impact from token impersonation can be severe.", + "ImpactStatement": "Token Protection currently supports native applications only. Browser-based applications are not supported. There are also many other known limitations documented in the link below: https://learn.microsoft.com/en-us/entra/identity/conditional-access/deployment-guide- token-protection-windows#known-limitations", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Select New policy. 4. Select Users or agents (Preview): 1. Under Include, select the users or groups to apply this policy. 2. Under Exclude exclude any break-glass accounts. 5. Select Target resources > Resources > Include > Select resources 1. Under Select specific resources, select the following applications: 1. Office 365 Exchange Online 2. Office 365 SharePoint Online 3. Microsoft Teams Services 2. Choose Select 6. Select Conditions: 1. Under Device platforms 1. Set Configure to Yes. 2. Include > Select device platforms > Windows. 3. Select Done. 2. Under Client apps: 1. Set Configure to Yes 2. Under Modern authentication clients, only select Mobile apps and desktop clients. 3. Select Done 7. Under Access controls > Session, select Require token protection for sign-in sessions (Generally available for Windows. Preview for MacOS, iOS) and click Select. 8. Under Enable policy set it to Report-only. 9. Click Create. 10. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria: o Under Users or agents (Preview) verify that None is not selected. o Verify that only documented exclusions exist and that they are reviewed annually o Under Target resources select Select resources and verify at minimum the following are checked: - Office 365 Exchange Online - Office 365 SharePoint Online - Microsoft Teams Services 4. Under Conditions > Device Platforms: verify that Configure is set to Yes and Include indicates Windows platforms. 5. Under Conditions > Client Apps: verify that Configure is set to Yes and only Mobile Apps and Desktop Clients is selected. 6. Under Access controls > Session, verify that Require token protection for sign-in sessions (Generally available for Windows. Preview for MacOS, iOS) is selected. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where sessionControls.secureSignInSession.isEnabled is true. 3. For each policy returned, verify the following criteria: o conditions.users.includeUsers is not None o conditions.applications.includeApplications contains at least the following GUIDs: - 00000002-0000-0ff1-ce00-000000000000 (Office 365 Exchange Online) - 00000003-0000-0ff1-ce00-000000000000 (Office 365 SharePoint Online) - cc15fd57-2c6c-4117-a88c-83b1d56b4bbe (Microsoft Teams Services) o conditions.platforms.includePlatforms contains windows o conditions.clientAppTypes is mobileAppsAndDesktopClients o sessionControls.secureSignInSession.isEnabled is true o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-token-protection:https://learn.microsoft.com/en-us/entra/identity/conditional-access/deployment-guide-token-protection-windows:https://learn.microsoft.com/en-us/entra/identity/devices/protecting-tokens-microsoft-entra-id" + } + ] + }, + { + "Id": "5.2.2.17", + "Description": "Authentication transfer is a flow that lets users seamlessly transfer authenticated state from one device to another. For example, users might see a QR code in the desktop version of Outlook that, when scanned on their mobile device, transfers their authenticated state to the mobile device. The recommended state is to block Authentication transfer.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Authentication transfer is a flow that lets users seamlessly transfer authenticated state from one device to another. For example, users might see a QR code in the desktop version of Outlook that, when scanned on their mobile device, transfers their authenticated state to the mobile device. The recommended state is to block Authentication transfer.", + "RationaleStatement": "Blocking authentication transfer helps protect against token theft and replay attacks by preventing the use of device tokens to silently authenticate on other devices or browsers. When authentication transfer is enabled, a threat actor who gains access to one device can access resources to unapproved devices, bypassing standard authentication and device compliance checks. When administrators block this flow, organizations can ensure that each authentication request must originate from the original device, maintaining the integrity of the device compliance and user session context.", + "ImpactStatement": "Users will no longer be able to use authentication transfer to sign into mobile versions of Microsoft apps (e.g., scanning a QR code in Outlook desktop to sign into Outlook mobile). Each device will require independent, interactive sign-in subject to applicable Conditional Access policies.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) include All users. o Under Target resources > Resources (formerly cloud apps) include All resources (formerly 'All cloud apps'). - Under Exclude exclude any break-glass accounts. o Under Conditions > Authentication flows set Configure to Yes and check Authentication transfer. - Click Save. o Under Grant select Block access and click Select. 4. Under Enable policy set it to Report-only until the organization is ready to enable it. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access. 3. Verify that a policy exists with the following criteria: o Under Users or agents (Preview) verify All users is selected. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify Resources (formerly cloud apps) includes All resources (formerly 'All cloud apps') o Under Conditions > Authentication flows verify Configure is set to Yes and Authentication transfer is checked. o Under Grant verify Block access is selected. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where conditions.authenticationFlows.transferMethods contains authenticationTransfer. 3. For each policy returned, verify the following criteria: o conditions.users.includeUsers is All o conditions.applications.includeApplications is All o conditions.authenticationFlows.transferMethods contains authenticationTransfer o grantControls.builtInControls is block o state is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-block-authentication-flows#authentication-transfer-policies:https://learn.microsoft.com/en-us/entra/fundamentals/zero-trust-protect-identities#authentication-transfer-is-blocked:https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-authentication-flows:https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-authentication-transfer" + } + ] + }, + { + "Id": "5.2.3.1", + "Description": "Microsoft provides supporting settings to enhance the configuration of the Microsoft Authenticator application. These settings provide users with additional information and context when they receive MFA passwordless and push requests, including the geographic location of the request, the requesting application, and a requirement for number matching. The recommended state is Enabled for the following: - Show application name in push and passwordless notifications - Show geographic location in push and passwordless notifications Note: On February 27, 2023 Microsoft started enforcing number matching tenant-wide for all users using Microsoft Authenticator.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft provides supporting settings to enhance the configuration of the Microsoft Authenticator application. These settings provide users with additional information and context when they receive MFA passwordless and push requests, including the geographic location of the request, the requesting application, and a requirement for number matching. The recommended state is Enabled for the following: - Show application name in push and passwordless notifications - Show geographic location in push and passwordless notifications Note: On February 27, 2023 Microsoft started enforcing number matching tenant-wide for all users using Microsoft Authenticator.", + "RationaleStatement": "As the use of strong authentication has become more widespread, attackers have started to exploit the tendency of users to experience \"MFA fatigue.\" This occurs when users are repeatedly asked to provide additional forms of identification, leading them to eventually approve requests without fully verifying the source. To counteract this, number matching can be employed to ensure the security of the authentication process. With this method, users are prompted to confirm a number displayed on their original device and enter it into the device being used for MFA. Additionally, other information such as geolocation and application details are displayed to enhance the end user's awareness. Among these 3 options, number matching provides the strongest net security gain.", + "ImpactStatement": "Additional interaction will be required by end users using number matching as opposed to simply pressing \"Approve\" for login attempts.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click to expand Entra ID > Authentication methods and select Policies. 3. Select Microsoft Authenticator 4. Under Enable and Target ensure the setting is set to Enable. 5. Select Configure 6. Set the following Microsoft Authenticator settings: o Show application name in push and passwordless notifications Status is set to Enabled, Target All users o Show geographic location in push and passwordless notifications Status is set to Enabled, Target All users Note: Valid groups such as break glass accounts can be excluded per organization policy.", + "AuditProcedure": "To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Authentication methods and select Policies. 3. Under Method select Microsoft Authenticator. 4. Under Enable and Target verify the setting is set to Enable. 5. In the Include tab verify that All users is selected. 6. In the Exclude tab verify only valid groups are present (i.e. Break Glass accounts). 7. Select Configure 8. Verify the following Microsoft Authenticator settings: o Show application name in push and passwordless notifications Status is set to Enabled, Target All users o Show geographic location in push and passwordless notifications Status is set to Enabled, Target All users 9. In each setting select Exclude and verify only valid groups are present (i.e. Break Glass accounts). To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/ microsoftAuthenticator 2. Verify that the state is enabled. 3. Verify that includeTargets.id is all_users. 4. Verify that the excludeTargets only includes valid groups (i.e. Break Glass accounts). 5. Under featureSettings verify the following settings: o displayAppInformationRequiredState.state is enabled o displayAppInformationRequiredState.includeTarget.id is all_users o displayLocationInformationRequiredState.state is enabled o displayLocationInformationRequiredState.includeTarget.id is all_users 6. In each setting excludeTarget only includes a valid group (i.e. Break Glass accounts) or a target id of 00000000-0000-0000-0000-000000000000 Note: Compliance cannot be easily validated for exclusions so adding these to a report for human manual review is recommended. These should be reviewed annually.", + "AdditionalInformation": "", + "DefaultValue": "Microsoft-managed", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-default-enablement:https://techcommunity.microsoft.com/t5/microsoft-entra-blog/defend-your-users-from-mfa-fatigue-attacks/ba-p/2365677:https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-mfa-number-match:https://learn.microsoft.com/en-us/graph/api/authenticationmethodspolicy-get?view=graph-rest-1.0" + } + ] + }, + { + "Id": "5.2.3.2", + "Description": "With Entra Password Protection, default global banned password lists are automatically applied to all users in an Entra ID tenant. To support business and security needs, custom banned password lists can be defined. When users change or reset their passwords, these banned password lists are checked to enforce the use of strong passwords. A custom banned password list should include some of the following examples: - Brand names - Product names - Locations, such as company headquarters - Company-specific internal terms - Abbreviations that have specific company meaning", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "With Entra Password Protection, default global banned password lists are automatically applied to all users in an Entra ID tenant. To support business and security needs, custom banned password lists can be defined. When users change or reset their passwords, these banned password lists are checked to enforce the use of strong passwords. A custom banned password list should include some of the following examples: - Brand names - Product names - Locations, such as company headquarters - Company-specific internal terms - Abbreviations that have specific company meaning", + "RationaleStatement": "Creating a new password can be difficult regardless of one's technical background. It is common to look around one's environment for suggestions when building a password, however, this may include picking words specific to the organization as inspiration for a password. An adversary may employ what is called a 'mangler' to create permutations of these specific words in an attempt to crack passwords or hashes making it easier to reach their goal.", + "ImpactStatement": "If a custom banned password list includes too many common dictionary words, or short words that are part of compound words, then perfectly secure passwords may be blocked. The organization should consider a balance between security and usability when creating a list.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Entra ID > Authentication methods and select Password protection. 3. Set Enforce custom list to Yes 4. In Custom banned password list create a list using suggestions outlined in this document. 5. Click Save Note: Below is a list of examples that can be used as a starting place. The references section contains more suggestions. - Brand names - Product names - Locations, such as company headquarters - Company-specific internal terms - Abbreviations that have specific company meaning", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Entra ID > Authentication methods and select Password protection. 3. Verify that Enforce custom list is set to Yes 4. Verify that Custom banned password list contains entries specific to the organization or matches a pre-determined list. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Directory.Read.All\" 2. Run the following commands: $PwRuleSettings = '5cf42378-d67d-4f36-ba46-e8b86229381d' Get-MgGroupSetting | Where-Object TemplateId -eq $PwRuleSettings | Select-Object -ExpandProperty Values 3. Verify that EnableBannedPasswordCheck is True and BannedPasswordList is populated.", + "AdditionalInformation": "Organization-specific terms can be added to the custom banned password list, such as the following examples: - Brand names - Product names - Locations, such as company headquarters - Company-specific terms - Abbreviations that have specific company meaning - Months and weekdays with your company's local languages The default global banned password list is already applied to your resources which applies the following basic requirements: Characters allowed: - Uppercase characters (A - Z) - Lowercase characters (a - z) - Numbers (0 - 9) - Symbols: - @#$%^&*-_!+=[]{}|\\:',.?/`~\"();<> - blank space Characters not allowed: - Unicode characters Password length: Passwords require: - A minimum of eight characters - A maximum of 256 characters Password complexity: Passwords require three out of four of the following categories: - Uppercase characters - Lowercase characters - Numbers - Symbols Note: Password complexity check isn't required for Education tenants. Password not recently used: - When a user changes or resets their password, the new password can't be the same as the current or recently used passwords. - Password isn't banned by Entra ID Password Protection. - The password can't be on the global list of banned passwords for Azure AD Password Protection, or on the customizable list of banned passwords specific to your organization. Evaluation New passwords are evaluated for strength and complexity by validating against the combined list of terms from the global and custom banned password lists. Even if a user's password contains a banned password, the password may be accepted if the overall password is otherwise strong enough.", + "DefaultValue": "By default the custom banned password list is not 'Enabled'.", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-password-ban-bad#custom-banned-password-list:https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-configure-custom-password-protection:https://www.microsoft.com/en-us/research/publication/password-guidance/:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-6-use-strong-authentication-controls" + } + ] + }, + { + "Id": "5.2.3.3", + "Description": "Microsoft Entra Password Protection provides a global and custom banned password list. A password change request fails if there's a match in these banned password list. To protect on-premises Active Directory Domain Services (AD DS) environment, install and configure Entra Password Protection. Note: This recommendation applies to Hybrid deployments only and will have no impact unless working with on-premises Active Directory.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra Password Protection provides a global and custom banned password list. A password change request fails if there's a match in these banned password list. To protect on-premises Active Directory Domain Services (AD DS) environment, install and configure Entra Password Protection. Note: This recommendation applies to Hybrid deployments only and will have no impact unless working with on-premises Active Directory.", + "RationaleStatement": "This feature protects an organization by prohibiting the use of weak or leaked passwords. In addition, organizations can create custom banned password lists to prevent their users from using easily guessed passwords that are specific to their industry. Deploying this feature to Active Directory will strengthen the passwords that are used in the environment.", + "ImpactStatement": "The potential impact associated with implementation of this setting is dependent upon the existing password policies in place in the environment. For environments that have strong password policies in place, the impact will be minimal. For organizations that do not have strong password policies in place, implementation of Microsoft Entra Password Protection may require users to change passwords and adhere to more stringent requirements than they have been accustomed to.", + "RemediationProcedure": "To remediate using the UI: - Download and install the Azure AD Password Proxies and DC Agents from the following location: https://www.microsoft.com/download/details.aspx?id=57071 After installed follow the steps below. 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Password protection. 3. Set Enable password protection on Windows Server Active Directory to Yes and Mode to Enforced.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Password protection. 3. Verify that Enable password protection on Windows Server Active Directory is set to Yes and that Mode is set to Enforced. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Directory.Read.All\" 2. Run the following command: (Get-MgGroupSetting | ? { $_.TemplateId -eq '5cf42378-d67d-4f36-ba46- e8b86229381d' }).Values 3. Verify that EnableBannedPasswordCheckOnPremises is set to True and BannedPasswordCheckOnPremisesMode is set to Enforce.", + "AdditionalInformation": "", + "DefaultValue": "Enable - Yes Mode - Audit", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/howto-password-ban-bad-on-premises-operations" + } + ] + }, + { + "Id": "5.2.3.4", + "Description": "Microsoft defines Multifactor authentication capable as being registered and enabled for a strong authentication method. The method must also be allowed by the authentication methods policy. Ensure all member users are MFA capable.", + "Checks": [ + "entra_users_mfa_capable" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft defines Multifactor authentication capable as being registered and enabled for a strong authentication method. The method must also be allowed by the authentication methods policy. Ensure all member users are MFA capable.", + "RationaleStatement": "Multifactor authentication requires an individual to present a minimum of two separate forms of authentication before access is granted. Users who are not MFA Capable have never registered a strong authentication method for multifactor authentication that is within policy and may not be using MFA. This could be a result of having never signed in, exclusion from a Conditional Access (CA) policy requiring MFA, or a CA policy does not exist. Reviewing this list of users will help identify possible lapses in policy or procedure.", + "ImpactStatement": "When using the UI audit method guest users will appear in the report and unless the organization is applying MFA rules to guests then they will need to be manually filtered. Accounts that provide on-premises directory synchronization also appear in these reports.", + "RemediationProcedure": "Remediation steps will depend on the status of the personnel in question or configuration of Conditional Access policies and will not be covered in detail. Administrators should review each user identified on a case-by-case basis using the conditions below. User has never signed on: - Employment status should be reviewed, and appropriate action taken on the user account's roles, licensing and enablement. Conditional Access policy applicability: - Ensure a CA policy is in place requiring all users to use MFA. - Ensure the user is not excluded from the CA MFA policy. - Ensure the policy's state is set to On. - Use What if to determine applicable CA policies. (Protection > Conditional Access > Policies) - Review the user account in Sign-in logs. Under the Activity Details pane click the Conditional Access tab to view applied policies. Note: Conditional Access is covered step by step in section 5.2.2", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select User registration details. 3. Set the filter option Multifactor authentication capable to Not Capable. 4. Review the non-guest users in this list. 5. Excluding any exceptions users found in this report may require remediation. To audit using PowerShell: 1. Connect to Graph using Connect-MgGraph -Scopes \"UserAuthenticationMethod.Read.All,AuditLog.Read.All\" 2. Run the following: Get-MgReportAuthenticationMethodUserRegistrationDetail ` -Filter \"IsMfaCapable eq false and UserType eq 'Member'\" | ft UserPrincipalName,IsMfaCapable,IsAdmin 3. Verify that IsMfaCapable is set to True. 4. Excluding any exceptions users found in this report may require remediation. Note: The CA rule must be in place for a successful deployment of Multifactor Authentication. This policy is outlined in the conditional access section 5.2.2 Note 2: Possible exceptions include on-premises synchronization accounts.", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.reports/update-mgreportauthenticationmethoduserregistrationdetail?view=graph-powershell-0#-ismfacapable:https://learn.microsoft.com/en-us/entra/identity/monitoring-health/how-to-view-applied-conditional-access-policies:https://learn.microsoft.com/en-us/entra/identity/conditional-access/what-if-tool:https://learn.microsoft.com/en-us/entra/identity/authentication/howto-authentication-methods-activity" + } + ] + }, + { + "Id": "5.2.3.5", + "Description": "Authentication methods support a wide variety of scenarios for signing in to Microsoft 365 resources. Some of these methods are inherently more secure than others but require more investment in time to get users enrolled and operational. SMS and Voice Call rely on telephony carrier communication methods to deliver the authenticating factor. The recommended state is to Disable these methods: - SMS - Voice Call", + "Checks": [ + "entra_authentication_method_sms_voice_disabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Authentication methods support a wide variety of scenarios for signing in to Microsoft 365 resources. Some of these methods are inherently more secure than others but require more investment in time to get users enrolled and operational. SMS and Voice Call rely on telephony carrier communication methods to deliver the authenticating factor. The recommended state is to Disable these methods: - SMS - Voice Call", + "RationaleStatement": "Traditional MFA methods such as SMS codes, email-based OTPs, and push notifications are becoming less effective against today's attackers. Sophisticated phishing campaigns have demonstrated that second factors can be intercepted or spoofed. Attackers now exploit social engineering, man-in-the-middle tactics, and user fatigue (e.g., MFA bombing) to bypass these mechanisms. These risks are amplified in distributed, cloud-first organizations with hybrid workforces and varied device ecosystems. The SMS and Voice call methods are vulnerable to SIM swapping which could allow an attacker to gain access to your Microsoft 365 account.", + "ImpactStatement": "There may be increased administrative overhead in adopting more secure authentication methods depending on the maturity of the organization.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Policies. 3. Inspect each method that is out of compliance and remediate: o Click on the method to open it. o Change the Enable toggle to the off position. o Click Save. Note: If the save button remains greyed out after toggling a method off, then first turn it back on and then change the position of the Target selection (all users or select groups). Turn the method off again and save. This was observed to be a bug in the UI at the time this document was published. To remediate using Powershell: 1. Connect to Graph using Connect-MgGraph -Scopes \"Policy.ReadWrite.AuthenticationMethod\" 2. Run the following to disable both authentication methods: $params = @( @{ Id = \"Sms\"; State = \"disabled\" }, @{ Id = \"Voice\"; State = \"disabled\" } ) Update-MgPolicyAuthenticationMethodPolicy -AuthenticationMethodConfigurations $params", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Policies. 3. Verify that the following methods in the Enabled column are set to No. o Method: SMS o Method: Voice call To audit using Powershell: 1. Connect to Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following: (Get-MgPolicyAuthenticationMethodPolicy).AuthenticationMethodConfigurations 3. Verify that Sms and Voice are disabled.", + "AdditionalInformation": "", + "DefaultValue": "- SMS : Disabled - Voice Call : Disabled", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-methods-manage:https://learn.microsoft.com/en-us/security/zero-trust/sfi/phishing-resistant-mfa#context-and-problem:https://www.microsoft.com/en-us/microsoft-365-life-hacks/privacy-and-safety/what-is-sim-swapping" + } + ] + }, + { + "Id": "5.2.3.6", + "Description": "System-preferred multifactor authentication (MFA) prompts users to sign in by using the most secure method they registered. The user is prompted to sign-in with the most secure method according to the below order. The order of authentication methods is dynamic. It's updated by Microsoft as the security landscape changes, and as better authentication methods emerge. 1. Temporary Access Pass 2. Passkey (FIDO2) 3. Microsoft Authenticator notifications 4. External authentication methods 5. Time-based one-time password (TOTP) 6. Telephony 7. Certificate-based authentication The recommended state is Enabled.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "System-preferred multifactor authentication (MFA) prompts users to sign in by using the most secure method they registered. The user is prompted to sign-in with the most secure method according to the below order. The order of authentication methods is dynamic. It's updated by Microsoft as the security landscape changes, and as better authentication methods emerge. 1. Temporary Access Pass 2. Passkey (FIDO2) 3. Microsoft Authenticator notifications 4. External authentication methods 5. Time-based one-time password (TOTP) 6. Telephony 7. Certificate-based authentication The recommended state is Enabled.", + "RationaleStatement": "Regardless of the authentication method enabled by an administrator or set as preferred by the user, the system will dynamically select the most secure option available at the time of authentication. This approach acts as an additional safeguard to prevent the use of weaker methods, such as voice calls, SMS, and email OTPs, which may have been inadvertently left enabled due to misconfiguration or lack of configuration hardening. Enforcing the default behavior also ensures the feature is not disabled.", + "ImpactStatement": "The Microsoft managed value of system-preferred MFA is Enabled and as such enforces the default behavior. No additional impact is expected. Note: Due to known issues with certificate-based authentication (CBA) and system- preferred MFA, Microsoft moved CBA to the bottom of the list. It is still considered a strong authentication method.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Settings. 3. Set the System-preferred multifactor authentication State to Enabled and include All users. 4. Any users exclusions should be documented and reviewed annually.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Settings. 3. Verify the System-preferred multifactor authentication State is set to Enabled and All users are included. 4. Verify that only documented exclusions exist and that they are reviewed annually To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.AuthenticationMethod\" 2. Run the following commands: $Uri = 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy' (Invoke-MgGraphRequest -Method GET -Uri $Uri).systemCredentialPreferences 3. Verify that includeTargets is set to all_users and state is set to enabled.", + "AdditionalInformation": "", + "DefaultValue": "Microsoft Managed (Enabled)", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-system-preferred-multifactor-authentication:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-system-preferred-multifactor-authentication#how-does-system-preferred-mfa-determine-the-most-secure-method" + } + ] + }, + { + "Id": "5.2.3.7", + "Description": "Authentication methods support a wide variety of scenarios for signing in to Microsoft 365 resources. Some of these methods are inherently more secure than others but require more investment in time to get users enrolled and operational. The email one-time passcode feature is a way to authenticate B2B collaboration users when they can't be authenticated through other means, such as Microsoft Entra ID, Microsoft account (MSA), or social identity providers. When a B2B guest user tries to redeem your invitation or sign in to your shared resources, they can request a temporary passcode, which is sent to their email address. Then they enter this passcode to continue signing in. The recommended state is to Disable email OTP.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Authentication methods support a wide variety of scenarios for signing in to Microsoft 365 resources. Some of these methods are inherently more secure than others but require more investment in time to get users enrolled and operational. The email one-time passcode feature is a way to authenticate B2B collaboration users when they can't be authenticated through other means, such as Microsoft Entra ID, Microsoft account (MSA), or social identity providers. When a B2B guest user tries to redeem your invitation or sign in to your shared resources, they can request a temporary passcode, which is sent to their email address. Then they enter this passcode to continue signing in. The recommended state is to Disable email OTP.", + "RationaleStatement": "Traditional MFA methods such as SMS codes, email-based OTPs, and push notifications are becoming less effective against today's attackers. Sophisticated phishing campaigns have demonstrated that second factors can be intercepted or spoofed. Attackers now exploit social engineering, man-in-the-middle tactics, and user fatigue (e.g., MFA bombing) to bypass these mechanisms. These risks are amplified in distributed, cloud-first organizations with hybrid workforces and varied device ecosystems.", + "ImpactStatement": "Disabling Email OTP will prevent one-time pass codes from being sent to unverified guest users accessing Microsoft 365 resources on the tenant such as \"@yahoo.com\". They will be required to use a personal Microsoft account, a managed Microsoft Entra account, be part of a federation or be configured as a guest in the host tenant's Microsoft Entra ID.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Policies. 3. Click on Email OTP. 4. Change the Enable toggle to the off position\\ 5. Click Save. Note: If the save button remains greyed out after toggling a method off, then first turn it back on and then change the position of the Target selection (all users or select groups). Turn the method off again and save. This was observed to be a bug in the UI at the time this document was published. To remediate using Powershell: 1. Connect to Graph using Connect-MgGraph -Scopes \"Policy.ReadWrite.AuthenticationMethod\" 2. Run the following: $params = @( @{ Id = \"Email\"; State = \"disabled\" } ) Update-MgPolicyAuthenticationMethodPolicy -AuthenticationMethodConfigurations $params", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Policies. 3. Verify that Email OTP is set to No in the Enabled column. To audit using Powershell: 1. Connect to Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following: (Get-MgPolicyAuthenticationMethodPolicy).AuthenticationMethodConfigurations 3. Verify the id type Email is set to disabled.", + "AdditionalInformation": "", + "DefaultValue": "- Email OTP : Enabled", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-methods-manage:https://learn.microsoft.com/en-us/entra/external-id/one-time-passcode:https://learn.microsoft.com/en-us/security/zero-trust/sfi/phishing-resistant-mfa#context-and-problem" + } + ] + }, + { + "Id": "5.2.3.8", + "Description": "The account lockout threshold determines how many failed login attempts are permitted prior to placing the account in a locked-out state and initiating a variable lockout duration. The recommended Lockout threshold is 10 or less.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "The account lockout threshold determines how many failed login attempts are permitted prior to placing the account in a locked-out state and initiating a variable lockout duration. The recommended Lockout threshold is 10 or less.", + "RationaleStatement": "Account lockout is a method of protecting against brute-force and password spray attacks. Once the lockout threshold has been exceeded, the account enters a locked- out state which prevents all login attempts for a variable duration. The lockout in combination with a reasonable duration reduces the total number of failed login attempts that a malicious actor can execute in a given period of time.", + "ImpactStatement": "If account lockout threshold is set too low (less than 3), users may experience frequent lockout events and the resulting security alerts may contribute to alert fatigue. If account lockout threshold is set too high (more than 10), malicious actors can programmatically execute more password attempts in a given period of time. In hybrid environments using pass-through authentication (PTA), the Microsoft Entra lockout threshold must be set lower than the AD DS account lockout threshold. If the AD DS threshold is equal to or lower than the Entra threshold, AD DS will lock the account before Entra smart lockout activates, bypassing the cloud-side protection and resulting in on-premises account lockouts that require manual administrator intervention to clear. Microsoft recommends configuring the AD DS lockout threshold to be at least two to three times greater than the Entra lockout threshold.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Password protection. 3. Set Lockout threshold to 10 or less. Note: In hybrid environments using pass-through authentication (PTA), Microsoft recommends to configure the AD DS account lockout threshold to be at least two to three times greater than the value set here to ensure Entra smart lockout activates before AD DS lockout.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Password protection. 3. Verify that Lockout threshold is set to 10 or less. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/groupSettings 2. Filter to Password Rule settings, where templateId is 5cf42378-d67d-4f36- ba46-e8b86229381d 3. Verify that LockoutThreshold is 10 or less.", + "AdditionalInformation": "NOTE: The variable number for failed login attempts allowed before lockout is prescribed by many security and compliance frameworks. The appropriate setting for this variable should be determined by the most restrictive security or compliance framework that your organization follows.", + "DefaultValue": "By default, Lockout threshold is set to 10.", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/howto-password-smart-lockout#manage-microsoft-entra-smart-lockout-values:https://learn.microsoft.com/en-us/graph/api/group-list-settings?view=graph-rest-0" + } + ] + }, + { + "Id": "5.2.3.9", + "Description": "The account lockout duration value determines how long an account retains the status of lockout, and therefore how long before a user can continue to attempt to login after passing the lockout threshold. The recommended state is Lockout duration in seconds is at least 60.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "The account lockout duration value determines how long an account retains the status of lockout, and therefore how long before a user can continue to attempt to login after passing the lockout threshold. The recommended state is Lockout duration in seconds is at least 60.", + "RationaleStatement": "Account lockout is a method of protecting against brute-force and password spray attacks. Once the lockout threshold has been exceeded, the account enters a locked- out state which prevents all login attempts for a variable duration. The lockout in combination with a reasonable duration reduces the total number of failed login attempts that a malicious actor can execute in a given period of time.", + "ImpactStatement": "If account lockout duration is set too low (less than 60 seconds), malicious actors can perform more password spray and brute-force attempts over a given period of time. If the account lockout duration is set too high (more than 300 seconds) users may experience inconvenient delays during lockout. In hybrid environments using pass-through authentication (PTA), the Microsoft Entra lockout duration must be set longer than the AD DS account lockout duration. Note that Entra lockout duration is configured in seconds while AD DS lockout duration is configured in minutes; verify the units when comparing the two values to ensure Entra smart lockout expires after AD DS lockout.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Password protection. 3. Set the Lockout duration in seconds to 60 or higher. 4. Click Save. Note: In hybrid environments using pass-through authentication (PTA), ensure the AD DS account lockout duration is shorter than the value set here. The Entra lockout duration is configured in seconds while AD DS lockout duration is configured in minutes; account for the unit difference when comparing the two values.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Password protection. 3. Verify that Lockout duration in seconds is set to 60 or higher. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/groupSettings 2. Filter to Password Rule settings, where templateId is 5cf42378-d67d-4f36- ba46-e8b86229381d 3. Verify that LockoutDurationInSeconds is greater than or equal to 60.", + "AdditionalInformation": "", + "DefaultValue": "By default, Lockout duration in seconds is set to 60.", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/howto-password-smart-lockout#manage-microsoft-entra-smart-lockout-values:https://learn.microsoft.com/en-us/graph/api/group-list-settings?view=graph-rest-0" + } + ] + }, + { + "Id": "5.2.3.10", + "Description": "Microsoft Entra ID includes a feature called Authenticator Lite, which embeds a subset of Microsoft Authenticator functionality into companion applications such as Outlook mobile. When enabled, users can satisfy MFA requirements using push notifications or time-based one-time passcodes (TOTP) directly from the companion application without installing the standalone Microsoft Authenticator app. The recommended state is Microsoft Authenticator on companion applications set to Disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra ID includes a feature called Authenticator Lite, which embeds a subset of Microsoft Authenticator functionality into companion applications such as Outlook mobile. When enabled, users can satisfy MFA requirements using push notifications or time-based one-time passcodes (TOTP) directly from the companion application without installing the standalone Microsoft Authenticator app. The recommended state is Microsoft Authenticator on companion applications set to Disabled.", + "RationaleStatement": "Authenticator Lite does not support application name or geographic location context in push notifications, regardless of tenant-wide Authenticator feature settings. These are key defenses against MFA fatigue attacks that are only available in the full Microsoft Authenticator app. Authenticator Lite also does not satisfy Conditional Access authentication strength requirements, does not support passwordless authentication, and does not support SSPR via push notifications. Disabling this feature ensures users authenticate through the full Microsoft Authenticator app where all available security protections are active.", + "ImpactStatement": "Users who have registered Authenticator Lite as their only MFA method will be unable to complete MFA until they install and register the standalone Microsoft Authenticator app. Administrators should communicate this change in advance and verify that affected users have registered an alternative MFA method before disabling this feature to avoid authentication lockouts.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Authentication methods and select Policies. 3. Under Method select Microsoft Authenticator. 4. Select Configure. 5. Set Microsoft Authenticator on companion applications: Status to Disabled. 6. Select Save.", + "AuditProcedure": "To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Authentication methods and select Policies. 3. Under Method select Microsoft Authenticator. 4. Select Configure. 5. Verify that Microsoft Authenticator on companion applications: Status is set to Disabled. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: beta/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/ microsoftAuthenticator 2. Verify that featureSettings.companionAppAllowedState.state is disabled.", + "AdditionalInformation": "", + "DefaultValue": "Microsoft managed (enabled as of June 26, 2023)", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-mfa-authenticator-lite:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-default-enablement:https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-mfa-additional-context" + } + ] + }, + { + "Id": "5.2.4.1", + "Description": "Enabling self-service password reset allows users to reset their own passwords in Entra ID. When users sign in to Microsoft 365, they will be prompted to enter additional contact information that will help them reset their password in the future. If combined registration is enabled additional information, outside of multi-factor, will not be needed. The recommended state is All. Note: Effective Oct. 1st, 2022, Microsoft will begin to enable combined registration for all users in Entra ID tenants created before August 15th, 2020. Tenants created after this date are enabled with combined registration by default.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.4 Password reset", + "Profile": "E3 Level 1", + "AssessmentStatus": "Manual", + "Description": "Enabling self-service password reset allows users to reset their own passwords in Entra ID. When users sign in to Microsoft 365, they will be prompted to enter additional contact information that will help them reset their password in the future. If combined registration is enabled additional information, outside of multi-factor, will not be needed. The recommended state is All. Note: Effective Oct. 1st, 2022, Microsoft will begin to enable combined registration for all users in Entra ID tenants created before August 15th, 2020. Tenants created after this date are enabled with combined registration by default.", + "RationaleStatement": "Enabling Self-Service Password Reset (SSPR) significantly reduces helpdesk interactions, streamlining support operations and improving user experience. Traditional methods involving temporary passwords pose notable security risks-they are often weak, predictable, and susceptible to interception. This creates a window of opportunity for threat actors to compromise accounts before users can update their credentials. SSPR minimizes credential exposure and strengthens overall identity protection.", + "ImpactStatement": "Users will be required to provide additional contact information in order to enroll in SSPR. Some light user education may be necessary, particularly for individuals who are accustomed to contacting the help desk for password reset assistance. In hybrid environments, SSPR writeback must be enabled before users are able to reset their passwords through self-service.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Properties. 3. Set Self service password reset enabled to All", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Properties. 3. Verify that Self service password reset enabled is set to All", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/microsoft-365/admin/add-users/let-users-reset-passwords?view=o365-worldwide:https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-enable-sspr:https://learn.microsoft.com/en-us/entra/identity/authentication/howto-registration-mfa-sspr-combined:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-writeback" + } + ] + }, + { + "Id": "5.2.4.2", + "Description": "Ensures that two alternate forms of identification are provided before allowing a password reset. The recommended state is Number of methods required to reset set to 2.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.4 Password reset", + "Profile": "E3 Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensures that two alternate forms of identification are provided before allowing a password reset. The recommended state is Number of methods required to reset set to 2.", + "RationaleStatement": "Requiring Multi-factor Authentication (MFA) for Self-service Password Reset (SSPR) strengthens the password reset process by confirming the user's identity with two separate methods of authentication. With multiple methods required for password reset, an attacker would have to compromise multiple authentication methods before resetting a user's password.", + "ImpactStatement": "If multiple methods are required for password reset and a user has lost access to other authentication methods, the resetting user will need an administrator with permissions to remove the lost authentication method. Policy and training are recommended to teach administrators to verify the identity of the requesting user so that social engineering is not an effective vector of compromise. If multifactor authentication is not currently enabled for all users, users with only one registered form of authentication will not be able to reset their passwords through SSPR until another form of authentication is registered. If multifactor authentication is already enabled for all users, the impact of this recommendation should be minimal.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Authentication methods. 3. Set the Number of methods required to reset to 2 4. Click Save", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Authentication methods. 3. Verify that Number of methods required to reset is set to 2", + "AdditionalInformation": "", + "DefaultValue": "By default, the Number of methods required to reset is 1.", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-enable-sspr:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-registration-mfa-sspr-combined:https://support.microsoft.com/en-us/account-billing/reset-your-work-or-school-password-using-security-info-23dde81f-08bb-4776-ba72-e6b72b9dda9e:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-methods" + } + ] + }, + { + "Id": "5.2.4.3", + "Description": "The Require users to register when signing in? setting controls whether users are prompted to register their self-service password reset (SSPR) authentication methods at their next sign-in. When set to Yes, users who have not yet registered are prompted to do so upon signing in. The Number of days before users are asked to re-confirm their authentication information setting designates the period of time before registered users are prompted to re-confirm their existing authentication information is still valid, up to a maximum of 730 days. If set to 0 days, registered users will never be prompted to re-confirm their authentication information.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.4 Password reset", + "Profile": "E3 Level 1", + "AssessmentStatus": "Manual", + "Description": "The Require users to register when signing in? setting controls whether users are prompted to register their self-service password reset (SSPR) authentication methods at their next sign-in. When set to Yes, users who have not yet registered are prompted to do so upon signing in. The Number of days before users are asked to re-confirm their authentication information setting designates the period of time before registered users are prompted to re-confirm their existing authentication information is still valid, up to a maximum of 730 days. If set to 0 days, registered users will never be prompted to re-confirm their authentication information.", + "RationaleStatement": "Without requiring users to register, users may never establish SSPR authentication methods, rendering the re-confirmation setting ineffective regardless of the value it is set to. When users do register authentication methods for self-service password reset (SSPR), those methods may become stale over time as phone numbers, email addresses, or other contact information changes. If re-confirmation is disabled, outdated recovery information persists indefinitely. An attacker who gains access to a former phone number or email address associated with a user's account can exploit that stale recovery information to reset the user's password and take over the account. Requiring registration and periodic re-confirmation ensures that the authentication methods on record remain accurate and under the user's control.", + "ImpactStatement": "Because both settings default to the compliant state, organizations that have not altered them will experience no impact. Re-enabling registration prompts unregistered users to register at their next sign-in; re-enabling re-confirmation prompts registered users to verify their information on the configured interval. Organizations with large user populations and short re-confirmation intervals should expect increased SSPR support volume.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Registration. 3. Set Require users to register when signing in? to Yes. 4. Set Number of days before users are asked to re-confirm their authentication information to any organization-approved value other than 0. 5. Click Save.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Registration. 3. Verify that Require users to register when signing in? is set to Yes. 4. Verify that Number of days before users are asked to re-confirm their authentication information is not set to 0.", + "AdditionalInformation": "", + "DefaultValue": "- Require users to register when signing in?: Yes - Number of days before users are asked to re-confirm their authentication information: 180", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-howitworks#registration:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-methods" + } + ] + }, + { + "Id": "5.2.4.4", + "Description": "This setting determines whether or not users receive an email to their primary and alternate email addresses notifying them when their own password has been reset via the Self-Service Password Reset portal. The recommended state is Notify users on password resets? is set to Yes.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.4 Password reset", + "Profile": "E3 Level 1", + "AssessmentStatus": "Manual", + "Description": "This setting determines whether or not users receive an email to their primary and alternate email addresses notifying them when their own password has been reset via the Self-Service Password Reset portal. The recommended state is Notify users on password resets? is set to Yes.", + "RationaleStatement": "User notification on password reset is a proactive way of confirming password reset activity. It helps the user to recognize unauthorized password reset activities.", + "ImpactStatement": "Users will receive emails alerting them to password changes to both their primary and alternate emails.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Notifications. 3. Set Notify users on password resets? to Yes 4. Click Save.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Notifications. 3. Verify that Notify users on password resets? is set to Yes.", + "AdditionalInformation": "", + "DefaultValue": "Yes", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-enable-sspr#set-up-notifications-and-customizations:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-howitworks#notifications:https://support.microsoft.com/en-us/account-billing/reset-your-work-or-school-password-using-security-info-23dde81f-08bb-4776-ba72-e6b72b9dda9e" + } + ] + }, + { + "Id": "5.2.4.5", + "Description": "This setting determines whether or not all global administrators receive an email to their primary email address when other administrators reset their own passwords via the Self-Service Password Reset Portal. The recommended state is Notify all admins when other admins reset their password? is set to Yes.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.4 Password reset", + "Profile": "E3 Level 1", + "AssessmentStatus": "Manual", + "Description": "This setting determines whether or not all global administrators receive an email to their primary email address when other administrators reset their own passwords via the Self-Service Password Reset Portal. The recommended state is Notify all admins when other admins reset their password? is set to Yes.", + "RationaleStatement": "Administrator accounts are sensitive. Any password reset activity notification, when sent to all Administrators, ensures that all Global Administrators can passively confirm if such a reset is a common pattern within their group. For example, if all Administrators change their password every 30 days, any password reset activity before that may require administrator(s) to evaluate any unusual activity and confirm its origin.", + "ImpactStatement": "All Global Administrators will receive a notification from Azure every time a password is reset. This is useful for auditing procedures to confirm that there are no out of the ordinary password resets for Administrators. There is additional overhead, however, in the time required for Global Administrators to audit the notifications. This setting is only useful if all Global Administrators pay attention to the notifications and audit each one.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Notifications. 3. Set Notify all admins when other admins reset their password? to Yes 4. Click Save", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Notifications. 3. Verify that Notify all admins when other admins reset their password? is set to Yes", + "AdditionalInformation": "", + "DefaultValue": "No", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-howitworks#notifications:https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-enable-sspr#set-up-notifications-and-customizations" + } + ] + }, + { + "Id": "5.3.1", + "Description": "Microsoft Entra Privileged Identity Management (PIM) provides just-in-time (JIT) activation workflows for privileged Entra ID and Microsoft 365 roles, enabling time- bound access with approval and justification requirements. Rather than holding permanent role assignments, users are made eligible for a role and must explicitly activate it when needed. PIM supports requiring multi-factor authentication at activation, mandatory justification, approval workflows, and configurable activation durations.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.3 ID Governance", + "Profile": "E5 Level 2", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra Privileged Identity Management (PIM) provides just-in-time (JIT) activation workflows for privileged Entra ID and Microsoft 365 roles, enabling time- bound access with approval and justification requirements. Rather than holding permanent role assignments, users are made eligible for a role and must explicitly activate it when needed. PIM supports requiring multi-factor authentication at activation, mandatory justification, approval workflows, and configurable activation durations.", + "RationaleStatement": "Permanent active role assignments expose privileged access continuously, regardless of whether a user is actively performing administrative tasks. If a permanently privileged account is compromised, an attacker immediately holds full role permissions with no time boundary. PIM eliminates standing privilege by requiring users to explicitly activate role assignments, scoping elevated access to a defined duration and requiring justification and, optionally, approval. This reduces the window of opportunity for both external attackers and insider threats to exploit privileged access.", + "ImpactStatement": "The implementation of Just in Time privileged access is likely to necessitate changes to administrator routine. Administrators will only be granted access to administrative roles when required. When administrators request role activation, they will need to document the reason for requiring role access, anticipated time required to have the access, and to reauthenticate to enable role access. Note: If all global admins become eligible then there will be no global admin to receive notifications, by default. Alerts are sent to TenantAdmins, including Global Administrators, by default. To ensure proper receipt, configure alerts to be sent to security or operations staff with valid email addresses or a security operations center. Otherwise, after adoption of this recommendation, alerts sent to TenantAdmins may go unreceived due to the lack of a licensed permanently active Global Administrator.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Roles & admins and select All roles. 3. For each user or group role assignment that is out of compliance: o Click on the role to open it in PIM. o Select the Active assignments tab. o Under action click Update or Remove. - If Update is selected, set the Assignment type to Eligible and click Save. - If Remove is selected, the assignment will be removed and the principal will no longer hold the role. 4. For each privileged role with a non-compliant service principal active assignment: 1. Open the Active assignments tab. 2. Click Update to modify the service principal assignment. 3. Uncheck Permanently assigned and set an appropriate end time to create a time-bound assignment based on business needs. 4. Click Save to apply the changes. 5. Repeat for any other privileged role assignments that are out of compliance. Note: CIS Safeguard 6.8, Define and Maintain Role-Based Access Control, recommends reviewing access on a recurring schedule, at least annually and more frequently as needed. This practice is strongly encouraged for service principals when defining time-bound assignments.", + "AuditProcedure": "Note: There is no programmatic way to reliably determine whether a principal is a designated break-glass account. Global Administrator assignments require manual review and judgment to confirm that any permanent assignments belong exclusively to the organization's two approved break-glass accounts. To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Roles & admins and select All roles. 3. Select Add filters and apply the Privileged filter to scope the review to built- in and custom privileged roles. 4. For each PRIVILEGED role that has one or more assignments, perform the following review of active assignments: 1. Select the role to open it. 2. Open the Active assignments tab. 3. For each assignment where Type is User or Group, verify that State is Activated. - A State of Assigned is permitted only for approved break-glass accounts assigned to the Global Administrator role, and no more than 2 such accounts may hold this permanent assignment. 4. For each assignment where Type is Service principal, verify that State is Assigned and an End time is designated. 5. Compliance is met when all privileged role assignments meet the verification requirements in step 4. Usage of PIM for management of other administrator roles is recommended but not required for compliance. To audit using Microsoft Graph API: 1. Execute a GET request to the following relative URI to retrieve privileged roles (custom or built-in): beta/roleManagement/directory/roleDefinitions?$filter=isPrivileged eq true&$select=id,displayName,isPrivileged,isBuiltIn,isEnabled 2. Execute a GET request to the following relative URI to retrieve all instances of active role assignments: v1.0/roleManagement/directory/roleAssignmentScheduleInstances?$expand=princip al 3. Correlate by matching each assignment instance's roleDefinitionId to the privileged role list's id to produce a list of privileged role assignment instances. 4. For each privileged role assignment instance, verify the appropriate condition for the principal type: o For users and groups (principal@odata.type is #microsoft.graph.user or #microsoft.graph.group), verify that assignmentType is Activated. An assignmentType of Assigned is permitted only for approved break-glass accounts assigned to the Global Administrator role, and no more than 2 such accounts may hold this permanent assignment. o For service principals (principal@odata.type is #microsoft.graph.servicePrincipal), verify that assignmentType is Assigned and endDateTime is defined (time-bound assignment). 5. Compliance is met when all privileged role assignments meet the verification requirements in step 4. Usage of PIM for management of other administrator roles is recommended but not required for compliance.", + "AdditionalInformation": "In addition to enforcing just-in-time activation for active privileged role assignments, organizations are encouraged to periodically review eligible PIM role assignments to confirm ongoing business justification and remove stale entries. Annual review at minimum is recommended. This is a governance process that requires manual judgment and is outside the scope of the automated compliance check for this recommendation.", + "DefaultValue": "Without Privileged Identity Management configured, all privileged role assignments are permanent active assignments with no expiration or activation requirement.", + "References": "https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-configure" + } + ] + }, + { + "Id": "5.3.2", + "Description": "Access reviews enable administrators to establish an efficient automated process for reviewing group memberships, access to enterprise applications, and role assignments. These reviews can be scheduled to recur regularly, with flexible options for delegating the task of reviewing membership to different members of the organization. When configured for guest users, access reviews can automatically remove access if no reviewer responds within the review period, enforcing a fail-closed posture for external identities.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.3 ID Governance", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Access reviews enable administrators to establish an efficient automated process for reviewing group memberships, access to enterprise applications, and role assignments. These reviews can be scheduled to recur regularly, with flexible options for delegating the task of reviewing membership to different members of the organization. When configured for guest users, access reviews can automatically remove access if no reviewer responds within the review period, enforcing a fail-closed posture for external identities.", + "RationaleStatement": "Access to groups and applications for guests can change over time. If a guest user's access to a particular resource goes unnoticed, they may unintentionally gain access to sensitive data if a member adds new files or data to the resource. Access reviews can help reduce the risks associated with outdated assignments by requiring a member of the organization to conduct the reviews. Furthermore, these reviews can enable a fail- closed mechanism to remove access to the subject if the reviewer does not respond to the review.", + "ImpactStatement": "Legitimate guest users may lose access to resources if designated reviewers fail to respond within the review window, requiring re-invitation and re-provisioning of access. Organizations with a large number of Microsoft 365 groups may face significant reviewer workload from monthly review cycles, which can lead to approval fatigue. - Microsoft Entra ID Governance licensing (included in Microsoft 365 E5) is required to configure access reviews. - As of January 15, 2026, a linked Azure subscription is required to use Entra ID Governance features for guest users.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand ID Governance and select Access reviews 3. Click New access review. 4. In the Resource review box click Select. 5. In Review Type set the following: o Select what to review choose Teams + Groups. o Review Scope to All Microsoft 365 groups with guest users. o Scope to Guest users only then click Next: Reviews. 6. In Reviews set the following: o Select reviewers to Group members or Selected users and groups, ensuring that at least one reviewer is assigned and that the guest is not performing a self-review. o Duration (in days) to 14 days or less. o Review recurrence to Monthly or more frequent. o Start date for the review, ensuring the review becomes active before the next audit date. o End to Never, then click Next: Settings. 7. In Settings set the following: o Auto apply results to resource is checked. o If reviewers don't respond to Remove access o Justification required is checked. o E-mail notifications is checked. o Reminders is checked. o Click Next: Review + Create 8. Click Create. To remediate using the Microsoft Graph API: To create a new access review, execute a POST request to the following relative URI. To update an existing review, execute a PATCH request to the same URI appended with the review's id: v1.0/identityGovernance/accessReviews/definitions The request body must include properties that satisfy the audit criteria above. The Graph API documentation provides complete sample request bodies in multiple languages including HTTP, PowerShell, and Python. See Reference 3 for details.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand ID Governance and select Access reviews 3. Inspect the access reviews, and verify an access review is created with the following criteria: o Overview: Scope is set to Guest users only and Review status is Active o Reviewers: At least one reviewer is assigned, and the reviewer is not a guest user. o Settings > General: - Mail notifications is set to Enable - Reminders is set to Enable o Settings > Reviewers: - Require reason on approval is set to Enable o Settings > Scheduling: - Frequency is set to Monthly or more frequent - Duration (in days) does not exceed 14 days - End is set to Never o Settings > When completed: - Auto apply results to resource is set to Enable - If reviewers don't respond is set to Remove access 4. The control is compliant if there is at least one access review for guests that meets all criteria above. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identityGovernance/accessReviews/definitions 2. Apply the following filters to identify access reviews targeting guest users: o scope.resourceScopes.query matches the pattern userType eq 'Guest' OR o scope.query matches the pattern userType eq 'Guest' 3. For each review that passes the filters above, verify the following criteria: o Recurrence is configured as monthly or more frequent: - recurrence.pattern.type is absoluteMonthly OR - recurrence.pattern.type is weekly o reviewers is not empty o settings.mailNotificationsEnabled is true o settings.instanceDurationInDays is 14 days or less o settings.reminderNotificationsEnabled is true o settings.justificationRequiredOnApproval is true o settings.autoApplyDecisionsEnabled is true o settings.defaultDecision is Deny o settings.recurrence.range.type is noEnd 4. The control is compliant if there is at least one access review for guests that meets all criteria above.", + "AdditionalInformation": "", + "DefaultValue": "By default access reviews are not configured.", + "References": "https://learn.microsoft.com/en-us/entra/id-governance/access-reviews-overview:https://learn.microsoft.com/en-us/entra/id-governance/create-access-review:https://learn.microsoft.com/en-us/graph/api/resources/accessreviewscheduledefinition?view=graph-rest-1.0" + } + ] + }, + { + "Id": "5.3.3", + "Description": "Access reviews in Microsoft Entra Privileged Identity Management (PIM) enable administrators to periodically validate whether users still require their privileged role assignments. These reviews can be scheduled to recur on a regular cadence and can be delegated to reviewers other than the role holders themselves, such as security auditors.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.3 ID Governance", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Access reviews in Microsoft Entra Privileged Identity Management (PIM) enable administrators to periodically validate whether users still require their privileged role assignments. These reviews can be scheduled to recur on a regular cadence and can be delegated to reviewers other than the role holders themselves, such as security auditors.", + "RationaleStatement": "Regular review of critical high privileged roles in Entra ID will help identify role drift, or potential malicious activity. This will enable the practice and application of \"separation of duties\" where even non-privileged users like security auditors can be assigned to review assigned roles in an organization. These reviews can optionally be configured to automatically remove access if a reviewer does not respond within the review window, though this recommendation conservatively sets non-response to result in no change to avoid inadvertent removal of privileged accounts including break-glass accounts.", + "ImpactStatement": "In order to avoid disruption reviewers who have the authority to revoke roles should be trusted individuals who understand the significance of access reviews. Additionally, the principle of separation of duties should be applied to ensure that no administrator is responsible for reviewing their own access levels. This will cause additional administrative overhead. If the reviews are configured to automatically revoke highly privileged roles like the Global Administrator role, then this could result in removing all Global Administrators from the organization. Care should be taken when configuring this setting especially in the case of break-glass accounts which would be included in the scope. - Microsoft Entra ID Governance licensing (included in Microsoft 365 E5) is required to configure access reviews.", + "RemediationProcedure": "Note: An access review is created for each role selected after completing the process. To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand ID Governance > Privileged Identity Management. 3. Select Microsoft Entra Roles under Manage. 4. Select Access reviews and click New. o Provide a name and description. o Set Frequency to Monthly or more frequently. o Set Duration (in days) to at most 14. o Set End to Never. o Set Users scope to All users and groups. o In Role select the directory roles outlined in the Additional Information section. o Set Assignment type to All active and eligible assignments. o Set Reviewers to member(s) responsible for this type of review, other than self. 5. In Upon completion settings set the following: o Auto apply results to resource to Enable. o If reviewers don't respond to No change. 6. In Advanced settings set the following: o Require reason on approval to Enable o Mail notifications to Enable o Reminders to Enable 7. Click Start to save and begin the review series. Warning: Care should be taken when configuring the If reviewers don't respond setting for Global Administrator reviews, if misconfigured break-glass accounts could automatically have roles revoked. Additionally, reviewers should be educated on the purpose of break-glass accounts to prevent accidental manual removal of roles. To remediate using the Microsoft Graph API: To create a new access review, execute a POST request to the following relative URI. To update an existing review, execute a PATCH request to the same URI appended with the review's id: v1.0/identityGovernance/accessReviews/definitions The request body must include properties that satisfy the audit criteria above. The Graph API documentation provides complete sample request bodies in multiple languages including HTTP, PowerShell, and Python. See Reference 3 for details", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand ID Governance > Privileged Identity Management. 3. Select Microsoft Entra Roles under Manage. 4. Select Access reviews 5. For each privileged role listed in the Additional Information section, verify an access review exists that meets the following criteria: o Overview: - Role assignment type is set to Eligible and Active - Scope is set to Everyone - Review status is Active o Reviewers: At least one reviewer is assigned, and the reviewer is not self- reviewing. o Settings > General: - Mail notifications is set to Enable - Reminders is set to Enable o Settings > Reviewers: - Require reason on approval is set to Enable o Settings > Scheduling: - Frequency is set to Monthly or more frequently - Duration (in days) does not exceed 14 days - End is set to Never o Settings > When completed: - Auto apply results to resource is set to Enable - If reviewers don't respond is set to No change To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identityGovernance/accessReviews/definitions 2. Apply the following filters to identify relevant access review definitions: o scope.resourceScopes.query matches the pattern /directory/roleDefinitions/ o scope.resourceScopes.query matches the GUID of one of the 6 privileged directory roles outlined in the Additional Information section. 3. For each review that passes the filters above, verify the following criteria: o Scoped to Eligible and Active role assignments: - scope.principalScopes.query contains /v1.0/users AND - scope.principalScopes.query contains /v1.0/groups o Recurrence is configured as monthly or more frequent: - recurrence.pattern.type is absoluteMonthly OR - recurrence.pattern.type is weekly - recurrence.range.startDate is in the past relative to the audit date o reviewers is not empty o settings.mailNotificationsEnabled is true o settings.reminderNotificationsEnabled is true o settings.justificationRequiredOnApproval is true o settings.instanceDurationInDays is less than or equal to 14 o settings.autoApplyDecisionsEnabled is true o settings.defaultDecision is None 4. The control is compliant when all 6 privileged directory roles have an associated access review definition that meets all the criteria listed above. Note: The 6 roles referenced and their associated GUIDs can be found in the Additional Information section.", + "AdditionalInformation": "The 6 privileged directory roles referenced in the audit and remediation procedures and their associated GUIDs are as follows: Role Name Role Definition GUID Global Administrator 62e90394-69f5-4237-9190-012177145e10 Privileged Role Administrator e8611ab8-c189-46e8-94e1-60213ab1f814 Exchange Administrator 29232cdf-9323-42fd-ade2-1d097af3e4de SharePoint Administrator f28a1f50-f6e7-4571-818b-6a12f2af6b6c Teams Administrator 69091246-20e8-4a56-aa4d-066075b2a7a8 Security Administrator 194ae4cb-b126-40b2-bd5b-6091b380977d", + "DefaultValue": "By default access reviews are not configured.", + "References": "https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-create-roles-and-resource-roles-review:https://learn.microsoft.com/en-us/entra/id-governance/access-reviews-overview:https://learn.microsoft.com/en-us/graph/api/resources/accessreviewscheduledefinition?view=graph-rest-1.0" + } + ] + }, + { + "Id": "5.3.4", + "Description": "Microsoft Entra Privileged Identity Management can be used to audit roles, allow just in time activation of roles and allow for periodic role attestation. Requiring approval before activation allows one of the selected approvers to first review and then approve the activation prior to PIM granted the role. The approver doesn't have to be a group member or owner. The recommended state is Require approval to activate for the Global Administrator role.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.3 ID Governance", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra Privileged Identity Management can be used to audit roles, allow just in time activation of roles and allow for periodic role attestation. Requiring approval before activation allows one of the selected approvers to first review and then approve the activation prior to PIM granted the role. The approver doesn't have to be a group member or owner. The recommended state is Require approval to activate for the Global Administrator role.", + "RationaleStatement": "Requiring approval for Global Administrator role activation enhances visibility and accountability every time this highly privileged role is used. This process reduces the risk of an attacker elevating a compromised account to the highest privilege level, as any activation must first be reviewed and approved by a trusted party. Note: This only acts as protection for eligible users that are activating a role. Directly assigning a role does not require an approval workflow so therefore it is important to implement and use PIM correctly.", + "ImpactStatement": "Approvers do not need to be assigned the same role or be members of the same group. It's important to have at least two approvers and an emergency access (break-glass) account to prevent a scenario where no Global Administrators are available. For example, if the last active Global Administrator leaves the organization, and only eligible but inactive Global Administrators remain, a trusted approver without the Global Administrator role or an emergency access account would be essential to avoid delays in critical administrative tasks.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand ID Governance > Privileged Identity Management. 3. Under Manage select Microsoft Entra Roles. 4. Under Manage select Roles. 5. Select Global Administrator in the list. 6. Select Role settings and click Edit. 7. Check the Require approval to activate box. 8. Add at least one approver. 9. Click Update.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand ID Governance > Privileged Identity Management. 3. Under Manage select Microsoft Entra Roles. 4. Under Manage select Roles. 5. Select Global Administrator in the list. 6. Select Role settings. 7. Verify that Require approval to activate is set to Yes. 8. Verify there is at least 1 approvers in the list. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI to retrieve the policyID for the Global Administrator-role: v1.0/policies/roleManagementPolicyAssignments?$filter=scopeId eq '/' and scopeType eq 'DirectoryRole' and roleDefinitionId eq '62e90394-69f5-4237- 9190-012177145e10'&$select=policyId 2. Execute a GET request to the following relative URI to retrieve the policy's Approval setting using the policyId from the previous call: v1.0/policies/roleManagementPolicies/{policyID}/rules/Approval_EndUser_Assign ment # Example https://graph.microsoft.com/v1.0/policies/roleManagementPolicies/DirectoryRol e_9c4d49a8-1f7a-4256-b1a2-b7cb0e7180f4_86522f3f-cfd0-4634-95a0- 38083127ca00/rules/Approval_EndUser_Assignment 3. Verify that isApprovalRequired is true. 4. Verify that approvalStages.primaryApprovers contains one or more valid users. Note: Approvers should be reviewed on a regular basis to ensure the members are active and valid.", + "AdditionalInformation": "", + "DefaultValue": "Require approval to activate : No.", + "References": "https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-configure:https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/groups-role-settings#require-approval-to-activate" + } + ] + }, + { + "Id": "5.3.5", + "Description": "Microsoft Entra Privileged Identity Management can be used to audit roles, allow just in time activation of roles and allow for periodic role attestation. Requiring approval before activation allows one of the selected approvers to first review and then approve the activation prior to PIM granted the role. The approver doesn't have to be a group member or owner. The recommended state is Require approval to activate for the Privileged Role Administrator role.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.3 ID Governance", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra Privileged Identity Management can be used to audit roles, allow just in time activation of roles and allow for periodic role attestation. Requiring approval before activation allows one of the selected approvers to first review and then approve the activation prior to PIM granted the role. The approver doesn't have to be a group member or owner. The recommended state is Require approval to activate for the Privileged Role Administrator role.", + "RationaleStatement": "This role grants the ability to manage assignments for all Microsoft Entra roles including the Global Administrator role. This role does not include any other privileged abilities in Microsoft Entra ID like creating or updating users. However, users assigned to this role can grant themselves or others additional privilege by assigning additional roles. Requiring approval for activation enhances visibility and accountability every time this highly privileged role is used. This process reduces the risk of an attacker elevating a compromised account to the highest privilege level, as any activation must first be reviewed and approved by a trusted party. Note: This only acts as protection for eligible users that are activating a role. Directly assigning a role does not require an approval workflow so therefore it is important to implement and use PIM correctly.", + "ImpactStatement": "Requiring approvers for automatic role assignment can slightly increase administrative overhead and add delays to tasks.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand ID Governance > Privileged Identity Management. 3. Under Manage select Microsoft Entra Roles. 4. Under Manage select Roles. 5. Select Privileged Role Administrator in the list. 6. Select Role settings and click Edit. 7. Check the Require approval to activate box. 8. Add at least one approver. 9. Click Update.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand ID Governance > Privileged Identity Management. 3. Under Manage select Microsoft Entra Roles. 4. Under Manage select Roles. 5. Select Privileged Role Administrator in the list. 6. Select Role settings. 7. Verify Require approval to activate is set to Yes. 8. Verify there is at least one approvers in the list. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI to retrieve the policyID for the Privileged Role Administrator-role: v1.0/policies/roleManagementPolicyAssignments?$filter=scopeId eq '/' and scopeType eq 'DirectoryRole' and roleDefinitionId eq 'e8611ab8-c189-46e8- 94e1-60213ab1f814'&$select=policyId 2. Execute a GET request to the following relative URI to retrieve the policy's Approval setting using the policyId from the previous call: v1.0/policies/roleManagementPolicies/{policyID}/rules/Approval_EndUser_Assign ment # Example https://graph.microsoft.com/v1.0/policies/roleManagementPolicies/DirectoryRol e_d1fdbf46-5729-4c53-a951-7ab677be380f_3679e0d0-412a-444d-b517- ab23973d6067/rules/Approval_EndUser_Assignment 3. Verify that isApprovalRequired is true. 4. Verify that approvalStages.primaryApprovers contains one or more valid users. Note: Approvers should be reviewed on a regular basis to ensure the members are active and valid.", + "AdditionalInformation": "", + "DefaultValue": "Require approval to activate : No.", + "References": "https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-configure:https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/groups-role-settings#require-approval-to-activate" + } + ] + }, + { + "Id": "6.1.1", + "Description": "The value False indicates that mailbox auditing on by default is turned on for the organization. Mailbox auditing on by default in the organization overrides the mailbox auditing settings on individual mailboxes. For example, if mailbox auditing is turned off for a mailbox (the AuditEnabled property on the mailbox is False), the default mailbox actions are still audited for the mailbox, because mailbox auditing on by default is turned on for the organization. Turning off mailbox auditing on by default ($true) has the following results: - Mailbox auditing is turned off for your organization. - From the time you turn off mailbox auditing on by default, no mailbox actions are audited, even if mailbox auditing is enabled on a mailbox (the AuditEnabled property on the mailbox is True). - Mailbox auditing isn't turned on for new mailboxes and setting the AuditEnabled property on a new or existing mailbox to True is ignored. - Any mailbox audit bypass association settings (configured by using the Set- MailboxAuditBypassAssociation cmdlet) are ignored. - Existing mailbox audit records are retained until the audit log age limit for the record expires. The recommended state for this setting is False at the organization level. This will enable auditing and enforce the default.", + "Checks": [ + "exchange_organization_mailbox_auditing_enabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.1 Audit", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "The value False indicates that mailbox auditing on by default is turned on for the organization. Mailbox auditing on by default in the organization overrides the mailbox auditing settings on individual mailboxes. For example, if mailbox auditing is turned off for a mailbox (the AuditEnabled property on the mailbox is False), the default mailbox actions are still audited for the mailbox, because mailbox auditing on by default is turned on for the organization. Turning off mailbox auditing on by default ($true) has the following results: - Mailbox auditing is turned off for your organization. - From the time you turn off mailbox auditing on by default, no mailbox actions are audited, even if mailbox auditing is enabled on a mailbox (the AuditEnabled property on the mailbox is True). - Mailbox auditing isn't turned on for new mailboxes and setting the AuditEnabled property on a new or existing mailbox to True is ignored. - Any mailbox audit bypass association settings (configured by using the Set- MailboxAuditBypassAssociation cmdlet) are ignored. - Existing mailbox audit records are retained until the audit log age limit for the record expires. The recommended state for this setting is False at the organization level. This will enable auditing and enforce the default.", + "RationaleStatement": "Enforcing the default ensures auditing was not turned off intentionally or accidentally. Auditing mailbox actions will allow forensics and IR teams to trace various malicious activities that can generate TTPs caused by inbox access and tampering.", + "ImpactStatement": "None - this is the default behavior as of 2019.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-OrganizationConfig -AuditDisabled $false", + "AuditProcedure": "To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-OrganizationConfig | Format-List AuditDisabled 3. Verify that AuditDisabled is set to False.", + "AdditionalInformation": "", + "DefaultValue": "False", + "References": "https://learn.microsoft.com/en-us/purview/audit-mailboxes?view=o365-worldwide:https://learn.microsoft.com/en-us/powershell/module/exchange/set-organizationconfig?view=exchange-ps#-auditdisabled" + } + ] + }, + { + "Id": "6.1.2", + "Description": "Mailbox audit logging is turned on by default in all organizations. This effort started in January 2019, and means that certain actions performed by mailbox owners, delegates, and admins are automatically logged. The corresponding mailbox audit records are available for admins to search in the mailbox audit log. Mailboxes and shared mailboxes have actions assigned to them individually in order to audit the data the organization determines valuable at the mailbox level. The recommended state per mailbox is AuditEnabled to True including all default audit actions with additional actions outlined below in the audit and remediation sections. Note: Audit (Standard) licensing allows for up to 180 days log retention as of October 2023.", + "Checks": [ + "exchange_user_mailbox_auditing_enabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.1 Audit", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Mailbox audit logging is turned on by default in all organizations. This effort started in January 2019, and means that certain actions performed by mailbox owners, delegates, and admins are automatically logged. The corresponding mailbox audit records are available for admins to search in the mailbox audit log. Mailboxes and shared mailboxes have actions assigned to them individually in order to audit the data the organization determines valuable at the mailbox level. The recommended state per mailbox is AuditEnabled to True including all default audit actions with additional actions outlined below in the audit and remediation sections. Note: Audit (Standard) licensing allows for up to 180 days log retention as of October 2023.", + "RationaleStatement": "Whether it is for regulatory compliance or for tracking unauthorized configuration changes in Microsoft 365, enabling mailbox auditing and ensuring the proper mailbox actions are accounted for allows for Microsoft 365 teams to run security operations, forensics or general investigations on mailbox activities. The following mailbox types ignore the organizational default and must have AuditEnabled set to True at the mailbox level in order to capture relevant audit data. - Resource Mailboxes - Public Folder Mailboxes - DiscoverySearch Mailbox", + "ImpactStatement": "Adding additional audit action types and increasing the AuditLogAgeLimit from 90 to 180 days will have a limited impact on mailbox storage. Mailbox audit log records are stored in a subfolder (named Audits) in the Recoverable Items folder in each user's mailbox. - Mailbox audit records count against the storage quota of the Recoverable Items folder. - Mailbox audit records also count against the folder limit for the Recoverable Items folder. A maximum of 3 million items (audit records) can be stored in the Audits subfolder. The following cmdlet in Exchange Online PowerShell can be run to display the size and number of items in the Audits subfolder in the Recoverable Items folder: Get-MailboxFolderStatistics -Identity -FolderScope RecoverableItems | Where-Object {$_.Name -eq 'Audits'} | Format-List FolderPath,FolderSize,ItemsInFolder Note: It's unlikely that mailbox auditing on by default impacts the storage quota or the folder limit for the Recoverable Items folder.", + "RemediationProcedure": "For each UserMailbox ensure AuditEnabled is True and the following audit actions are included in addition to default actions of each sign-in type. - Admin actions: Copy, FolderBind and Move. - Delegate actions: FolderBind and Move. - Owner actions: Create, MailboxLogin and Move. Note: The defaults can be found in the Default Value section and the combined total can be found in the scripts of the Audit/Remediation sections. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell script to remediate every 'UserMailbox' in the organization: $AuditAdmin = @( \"ApplyRecord\", \"Copy\", \"Create\", \"FolderBind\", \"HardDelete\", \"MailItemsAccessed\", \"Move\", \"MoveToDeletedItems\", \"SendAs\", \"SendOnBehalf\", \"Send\", \"SoftDelete\", \"Update\", \"UpdateCalendarDelegation\", \"UpdateFolderPermissions\", \"UpdateInboxRules\" ) $AuditDelegate = @( \"ApplyRecord\", \"Create\", \"FolderBind\", \"HardDelete\", \"Move\", \"MailItemsAccessed\", \"MoveToDeletedItems\", \"SendAs\", \"SendOnBehalf\", \"SoftDelete\", \"Update\", \"UpdateFolderPermissions\", \"UpdateInboxRules\" ) $AuditOwner = @( \"ApplyRecord\", \"Create\", \"HardDelete\", \"MailboxLogin\", \"Move\", \"MailItemsAccessed\", \"MoveToDeletedItems\", \"Send\", \"SoftDelete\", \"Update\", \"UpdateCalendarDelegation\", \"UpdateFolderPermissions\", \"UpdateInboxRules\" ) $MBX = Get-EXOMailbox -ResultSize Unlimited | Where-Object { $_.RecipientTypeDetails -eq \"UserMailbox\" } $MBX | Set-Mailbox -AuditEnabled $true ` -AuditLogAgeLimit 180 -AuditAdmin $AuditAdmin -AuditDelegate $AuditDelegate ` -AuditOwner $AuditOwner 3. The script will apply the prescribed Audit Actions for each sign-in type (Owner, Delegate, Admin) and the AuditLogAgeLimit to each UserMailbox in the organization. Note: Mailboxes with Audit (Premium) licenses, which is included with E5, can retain audit logs beyond 180 days.", + "AuditProcedure": "Inspect each UserMailbox and ensure AuditEnabled is True and the following audit actions are included in addition to default actions of each sign-in type. - Admin actions: Copy, FolderBind and Move. - Delegate actions: FolderBind and Move. - Owner actions: Create, MailboxLogin and Move. Note: The defaults can be found in the Default Value section and the combined total can be found in the scripts of the Audit/Remediation sections. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell script: $AdminActions = @( \"ApplyRecord\", \"Copy\", \"Create\", \"FolderBind\", \"HardDelete\", \"MailItemsAccessed\", \"Move\", \"MoveToDeletedItems\", \"SendAs\", \"SendOnBehalf\", \"Send\", \"SoftDelete\", \"Update\", \"UpdateCalendarDelegation\", \"UpdateFolderPermissions\", \"UpdateInboxRules\" ) $DelegateActions = @( \"ApplyRecord\", \"Create\", \"FolderBind\", \"HardDelete\", \"Move\", \"MailItemsAccessed\", \"MoveToDeletedItems\", \"SendAs\", \"SendOnBehalf\", \"SoftDelete\", \"Update\", \"UpdateFolderPermissions\", \"UpdateInboxRules\" ) $OwnerActions = @( \"ApplyRecord\", \"Create\", \"HardDelete\", \"MailboxLogin\", \"Move\", \"MailItemsAccessed\", \"MoveToDeletedItems\", \"Send\", \"SoftDelete\", \"Update\", \"UpdateCalendarDelegation\", \"UpdateFolderPermissions\", \"UpdateInboxRules\" ) function VerifyActions { param ( [array]$ExpectedActions, [array]$ActualActions ) $Missing = $ExpectedActions | Where-Object { $_ -notin $ActualActions } return $Missing } $MBX = Get-EXOMailbox -PropertySets Audit, Minimum -ResultSize Unlimited | Where-Object { $_.RecipientTypeDetails -eq \"UserMailbox\" } $Results = foreach ($mailbox in $MBX) { $AdminMissing = VerifyActions -ExpectedActions $AdminActions ` -ActualActions $mailbox.AuditAdmin $DelegateMissing = VerifyActions -ExpectedActions $DelegateActions ` -ActualActions $mailbox.AuditDelegate $OwnerMissing = VerifyActions -ExpectedActions $OwnerActions ` -ActualActions $mailbox.AuditOwner $IsCompliant = $AdminMissing.Count -eq 0 -and $DelegateMissing.Count -eq 0 -and $OwnerMissing.Count -eq 0 -and $mailbox.AuditEnabled [PSCustomObject]@{ Mailbox = $mailbox.UserPrincipalName AuditEnabled = $mailbox.AuditEnabled AdminMissing = if ($AdminMissing.Count -gt 0) { $AdminMissing -join \", \" } else { \"None\" } DelegateMissing = if ($DelegateMissing.Count -gt 0) { $DelegateMissing -join \", \" } else { \"None\" } OwnerMissing = if ($OwnerMissing.Count -gt 0) { $OwnerMissing -join \", \" } else { \"None\" } ComplianceState = if ($IsCompliant) { \"Compliant\" } else { \"Non-Compliant\" } } } # Display results in table format $Results | Format-Table -AutoSize <# Optional: Export methods $Results | Out-GridView -Title \"Mailbox Audit Results\" $Results | Export-Csv -Path \"6.1.2.csv\" -NoTypeInformation $Results | ConvertTo-Json | Out-File -FilePath \"6.1.2.json\" #> 3. Inspect the results. Mailboxes will be labeled as either Compliant or Non- compliant, accompanied by supporting details that outline the missing actions for each type and the current state of AuditEnabled. Optional methods for exporting the data to CSV, JSON, or GridView are also shown at the end of the script. Note: Mailboxes with Audit (Premium) licenses, which is included with E5, can retain audit logs beyond 180 days.", + "AdditionalInformation": "", + "DefaultValue": "AuditEnabled: True for all mailboxes except below: - Resource Mailboxes - Public Folder Mailboxes - DiscoverySearch Mailbox AuditAdmin: ApplyRecord, Create, HardDelete, MailItemsAccessed, MoveToDeletedItems, Send, SendAs, SendOnBehalf, SoftDelete, Update, UpdateCalendarDelegation, UpdateFolderPermissions, UpdateInboxRules AuditDelegate: ApplyRecord, Create, HardDelete, MailItemsAccessed, MoveToDeletedItems, SendAs, SendOnBehalf, SoftDelete, Update, UpdateFolderPermissions, UpdateInboxRules AuditOwner: ApplyRecord, HardDelete, MailItemsAccessed, MoveToDeletedItems, Send, SoftDelete, Update, UpdateCalendarDelegation, UpdateFolderPermissions, UpdateInboxRules", + "References": "https://learn.microsoft.com/en-us/purview/audit-mailboxes?view=o365-worldwide" + } + ], + "ConfigRequirements": [ + { + "Check": "exchange_user_mailbox_auditing_enabled", + "ConfigKey": "audit_log_age", + "Operator": "gte", + "Value": 90 + } + ] + }, + { + "Id": "6.1.3", + "Description": "When configuring a user or computer account to bypass mailbox audit logging, the system will not record any access, or actions performed by the said user or computer account on any mailbox. Administratively this was introduced to reduce the volume of entries in the mailbox audit logs on trusted user or computer accounts. Ensure AuditBypassEnabled is not enabled on accounts without a written exception.", + "Checks": [ + "exchange_mailbox_audit_bypass_disabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.1 Audit", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "When configuring a user or computer account to bypass mailbox audit logging, the system will not record any access, or actions performed by the said user or computer account on any mailbox. Administratively this was introduced to reduce the volume of entries in the mailbox audit logs on trusted user or computer accounts. Ensure AuditBypassEnabled is not enabled on accounts without a written exception.", + "RationaleStatement": "If a mailbox audit bypass association is added for an account, the account can access any mailbox in the organization to which it has been assigned access permissions, without generating any mailbox audit logging entries for such access or recording any actions taken, such as message deletions. Enabling this parameter, whether intentionally or unintentionally, could allow insiders or malicious actors to conceal their activity on specific mailboxes. Ensuring proper logging of user actions and mailbox operations in the audit log will enable comprehensive incident response and forensics.", + "ImpactStatement": "None - this is the default behavior.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. The following example PowerShell script will disable AuditBypass for all mailboxes which currently have it enabled: # Get mailboxes with AuditBypassEnabled set to $true $MBXAudit = Get-MailboxAuditBypassAssociation -ResultSize unlimited | Where- Object { $_.AuditBypassEnabled -eq $true } foreach ($mailbox in $MBXAudit) { $mailboxName = $mailbox.Name Set-MailboxAuditBypassAssociation -Identity $mailboxName - AuditBypassEnabled $false Write-Host \"Audit Bypass disabled for mailbox Identity: $mailboxName\" - ForegroundColor Green }", + "AuditProcedure": "To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: $MBXData = Get-MailboxAuditBypassAssociation -ResultSize unlimited $Report = $MBXData | ? {$_.AuditBypassEnabled -eq $true} | select Name,AuditBypassEnabled $Report <# Optional: Export methods $Report | Out-GridView -Title \"Mailbox Audit Bypass Association\" $Report | Export-Csv -Path \"6.1.3.csv\" -NoTypeInformation #> 3. If nothing is returned, then there are no accounts with Audit Bypass enabled. Note: The cmdlet Get-MailboxAuditBypassAssociation may display a WARNING on system objects that begin with \"Asc-2X1\", this is not part of the Audit procedure and can be ignored.", + "AdditionalInformation": "", + "DefaultValue": "AuditBypassEnabled False", + "References": "https://learn.microsoft.com/en-us/powershell/module/exchange/get-mailboxauditbypassassociation?view=exchange-ps" + } + ] + }, + { + "Id": "6.2.1", + "Description": "Exchange Online offers several methods of managing the flow of email messages. These are Remote domain, Transport Rules, and Anti-spam outbound policies. These methods work together to provide comprehensive coverage for potential automatic forwarding channels: - Outlook forwarding using inbox rules. - Outlook forwarding configured using OOF rule. - OWA forwarding setting (ForwardingSmtpAddress). - Forwarding set by the admin using EAC (ForwardingAddress). - Forwarding using Power Automate / Flow. Ensure a Transport rule and Anti-spam outbound policy are used to block mail forwarding. NOTE: Any exclusions should be implemented based on organizational policy.", + "Checks": [ + "exchange_transport_rules_mail_forwarding_disabled", + "defender_antispam_outbound_policy_forwarding_disabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.2 Mail flow", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Exchange Online offers several methods of managing the flow of email messages. These are Remote domain, Transport Rules, and Anti-spam outbound policies. These methods work together to provide comprehensive coverage for potential automatic forwarding channels: - Outlook forwarding using inbox rules. - Outlook forwarding configured using OOF rule. - OWA forwarding setting (ForwardingSmtpAddress). - Forwarding set by the admin using EAC (ForwardingAddress). - Forwarding using Power Automate / Flow. Ensure a Transport rule and Anti-spam outbound policy are used to block mail forwarding. NOTE: Any exclusions should be implemented based on organizational policy.", + "RationaleStatement": "Attackers often create these rules to exfiltrate data from your tenancy, this could be accomplished via access to an end-user account or otherwise. An insider could also use one of these methods as a secondary channel to exfiltrate sensitive data.", + "ImpactStatement": "Care should be taken before implementation to ensure there is no business need for case-by-case auto-forwarding. Disabling auto-forwarding to remote domains will affect all users in an organization. Any exclusions should be implemented based on organizational policy.", + "RemediationProcedure": "Note: Remediation is a two step procedure as follows: STEP 1: Transport rules To remediate using the UI: 1. Select Exchange to open the Exchange admin center. 2. Select Mail Flow then Rules. 3. For each rule that redirects email to external domains, select the rule and click the 'Delete' icon. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Remove-TransportRule {RuleName} STEP 2: Anti-spam outbound policy To remediate using the UI: 1. Navigate to Microsoft 365 Defender https://security.microsoft.com/ 2. Expand E-mail & collaboration then select Policies & rules. 3. Select Threat policies > Anti-spam. 4. Select Anti-spam outbound policy (default) 5. Click Edit protection settings 6. Set Automatic forwarding rules dropdown to Off - Forwarding is disabled and click Save 7. Repeat steps 4-6 for any additional higher priority, custom policies. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-HostedOutboundSpamFilterPolicy -Identity {policyName} -AutoForwardingMode Off 3. To remove AutoForwarding from all outbound policies you can also run: Get-HostedOutboundSpamFilterPolicy | Set-HostedOutboundSpamFilterPolicy - AutoForwardingMode Off", + "AuditProcedure": "Note: Audit is a two step procedure as follows: STEP 1: Transport rules To audit using the UI: 1. Select Exchange to open the Exchange admin center. 2. Select Mail Flow then Rules. 3. Review the rules and verify that none of them are forwards or redirects e-mail to external domains. To audit using PowerShell: 1. Connect to Exchange online using Connect-ExchangeOnline. 2. Run the following PowerShell command to review the Transport Rules that are redirecting email: Get-TransportRule | Where-Object {$_.RedirectMessageTo -ne $null} | ft Name,RedirectMessageTo 3. Verify that none of the addresses listed belong to external domains outside of the organization. If nothing returns then there are no transport rules set to redirect messages. STEP 2: Anti-spam outbound policy To audit using the UI: 1. Navigate to Microsoft 365 Defender https://security.microsoft.com/ 2. Expand E-mail & collaboration then select Policies & rules. 3. Select Threat policies > Anti-spam. 4. Inspect Anti-spam outbound policy (default) and ensure Automatic forwarding is set to Off - Forwarding is disabled 5. Inspect any additional custom outbound policies and ensure Automatic forwarding is set to Off - Forwarding is disabled, in accordance with the organization's exclusion policies. To audit using PowerShell: 1. Connect to Exchange online using Connect-ExchangeOnline. 2. Run the following PowerShell cmdlet: Get-HostedOutboundSpamFilterPolicy | ft Name, AutoForwardingMode 3. In each outbound policy verify AutoForwardingMode is Off. Note: According to Microsoft if a recipient is defined in multiple policies of the same type (anti-spam, anti-phishing, etc.), only the policy with the highest priority is applied to the recipient. Any remaining policies of that type are not evaluated for the recipient (including the default policy). However, it is our recommendation to audit the default policy as well in the case a higher priority custom policy is removed. This will keep the organization's security posture strong.", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/exchange/security-and-compliance/mail-flow-rules/mail-flow-rules:https://techcommunity.microsoft.com/t5/exchange-team-blog/all-you-need-to-know-about-automatic-email-forwarding-in/ba-p/2074888#:~:text=%20%20%20Automatic%20forwarding%20option%20%20,%:https://learn.microsoft.com/en-us/defender-office-365/outbound-spam-policies-external-email-forwarding?view=o365-worldwide" + } + ] + }, + { + "Id": "6.2.2", + "Description": "Mail flow rules (transport rules) in Exchange Online can be configured to set the spam confidence level (SCL) of a message to -1, which bypasses spam and phishing filtering. When a rule applies this action to messages based on the sender's domain, all mail from that domain is treated as trusted and skips anti-malware and anti-phishing evaluation regardless of message content.", + "Checks": [ + "exchange_transport_rules_whitelist_disabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.2 Mail flow", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Mail flow rules (transport rules) in Exchange Online can be configured to set the spam confidence level (SCL) of a message to -1, which bypasses spam and phishing filtering. When a rule applies this action to messages based on the sender's domain, all mail from that domain is treated as trusted and skips anti-malware and anti-phishing evaluation regardless of message content.", + "RationaleStatement": "Whitelisting domains in transport rules bypasses regular malware and phishing scanning, which can enable an attacker to launch attacks against your users from a safe haven domain. Note: If an organization identifies a business need for an exception, the domain should only be whitelisted if inbound emails from that domain originate from a specific IP address. These exceptions should be documented and regularly reviewed.", + "ImpactStatement": "Removing SCL bypass rules will subject previously whitelisted domains to standard spam and phishing filtering. Mail from those domains that does not pass filtering may be quarantined or rejected, which could disrupt established business communications. Prior to removal, identify any rules in scope and coordinate with affected business owners. If a legitimate need exists, consider replacing domain-based whitelisting with approved sender lists at the connection level.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Exchange admin center https://admin.exchange.microsoft.com 2. Click to expand Mail Flow and then select Rules. 3. For each rule that sets the spam confidence level to -1 for a specific domain, select the rule and click Delete. To remediate using PowerShell: 1. Connect to Exchange online using Connect-ExchangeOnline. 2. To remove a specific non-compliant rule: Remove-TransportRule -Identity \"RuleName\" Note: If the rule serves a legitimate purpose beyond domain whitelisting, consider modifying it to remove the SenderDomainIs condition or the SetSCL -1 action rather than deleting it entirely.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Exchange admin center https://admin.exchange.microsoft.com 2. Click to expand Mail Flow and then select Rules. 3. Review each rule and ensure that a single rule does not contain both of these properties together: o Under Apply this rule if: Sender's address domain portion belongs to any of these domains: '' o Under Do the following: Set the spam confidence level (SCL) to '-1' Note: Setting the spam confidence level to -1 indicates the message is from a trusted sender, so the message bypasses spam filtering. The recommendation fails if any external domain has a SCL of -1. To audit using PowerShell: 1. Connect to Exchange online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-TransportRule | Where-Object { $_.setscl -eq -1 -and $_.SenderDomainIs - ne $null } | ft Name,SenderDomainIs,SetSCL 3. Transport rules that fail the audit will be shown. If no output is shown, the recommendation passes. To pass, all rules with SetSCL set to -1 must not include any domains in the SenderDomainIs property.", + "AdditionalInformation": "", + "DefaultValue": "No mail flow rules that set the SCL to -1 based on sender domain exist by default.", + "References": "https://learn.microsoft.com/en-us/exchange/security-and-compliance/mail-flow-rules/configuration-best-practices:https://learn.microsoft.com/en-us/exchange/security-and-compliance/mail-flow-rules/mail-flow-rules" + } + ] + }, + { + "Id": "6.2.3", + "Description": "External callouts provide a native experience to identify emails from senders outside the organization. This is achieved by presenting a new tag on emails called \"External\" (the string is localized based on the client language setting) and exposing related user interface at the top of the message reading view to see and verify the real sender's email address. The recommended state is ExternalInOutlook set to Enabled True", + "Checks": [ + "exchange_external_email_tagging_enabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.2 Mail flow", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "External callouts provide a native experience to identify emails from senders outside the organization. This is achieved by presenting a new tag on emails called \"External\" (the string is localized based on the client language setting) and exposing related user interface at the top of the message reading view to see and verify the real sender's email address. The recommended state is ExternalInOutlook set to Enabled True", + "RationaleStatement": "Tagging emails from external senders helps to inform end users about the origin of the email. This can allow them to proceed with more caution and make informed decisions when it comes to identifying spam or phishing emails. Mail flow rules are often used by Exchange administrators to accomplish the External email tagging by appending a tag to the front of a subject line. There are limitations to this outlined here. The preferred method in the CIS Benchmark is to use the native experience. Note: Existing emails in a user's inbox from external senders are not tagged retroactively.", + "ImpactStatement": "Mail flow rules using external tagging must be disabled, along with third-party mail filtering tools that offer similar features, to avoid duplicate [External] tags. External tags can consume additional screen space on systems with limited real estate, such as thin clients or mobile devices. After enabling this feature via PowerShell, it may take 24-48 hours for users to see the External sender tag in emails from outside your organization. Rolling back the feature takes the same amount of time. Note: Third-party tools that provide similar functionality will also meet compliance requirements, although Microsoft recommends using the native experience for better interoperability.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to Exchange online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-ExternalInOutlook -Enabled $true", + "AuditProcedure": "To audit using PowerShell: 1. Connect to Exchange online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-ExternalInOutlook 3. For each identity verify Enabled is set to True and the AllowList only contains email addresses the organization has permitted to bypass external tagging.", + "AdditionalInformation": "", + "DefaultValue": "Disabled (False)", + "References": "https://techcommunity.microsoft.com/t5/exchange-team-blog/native-external-sender-callouts-on-email-in-outlook/ba-p/2250098:https://learn.microsoft.com/en-us/powershell/module/exchange/set-externalinoutlook?view=exchange-ps" + } + ] + }, + { + "Id": "6.3.1", + "Description": "Role assignment policies in Exchange Online control whether users can install and manage add-ins for Outlook. Three management roles govern this capability: My Custom Apps allows users to sideload custom add-ins, My Marketplace Apps allows users to install add-ins from the marketplace, and My ReadWriteMailbox Apps allows users to install add-ins that request read/write mailbox permissions. When these roles are assigned to a user's role assignment policy, users can self-install add-ins in both Outlook desktop and Outlook on the web, granting those add-ins access to mailbox data. This recommendation applies to the default role assignment policy, which is automatically assigned to new mailboxes unless a custom policy is specified.", + "Checks": [ + "exchange_roles_assignment_policy_addins_disabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.3 Roles", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Role assignment policies in Exchange Online control whether users can install and manage add-ins for Outlook. Three management roles govern this capability: My Custom Apps allows users to sideload custom add-ins, My Marketplace Apps allows users to install add-ins from the marketplace, and My ReadWriteMailbox Apps allows users to install add-ins that request read/write mailbox permissions. When these roles are assigned to a user's role assignment policy, users can self-install add-ins in both Outlook desktop and Outlook on the web, granting those add-ins access to mailbox data. This recommendation applies to the default role assignment policy, which is automatically assigned to new mailboxes unless a custom policy is specified.", + "RationaleStatement": "Attackers exploit vulnerable or malicious add-ins to read, exfiltrate, or modify mailbox content including email, calendar items, and contacts. Restricting user-installed add-ins reduces this attack surface and centralizes add-in approval with administrators.", + "ImpactStatement": "End users will be unable to self-install third-party Outlook add-ins. Administrators may receive requests to evaluate and deploy add-ins on behalf of users. Organizations that rely on user-deployed add-ins for business workflows should inventory those add-ins and deploy them centrally via Centralized Deployment before implementing this recommendation.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Exchange admin center https://admin.exchange.microsoft.com. 2. Click to expand Roles and select User roles. 3. Select Default Role Assignment Policy. 4. In the properties pane on the right click on Manage permissions. 5. Under Other roles uncheck any non-compliant roles: My Custom Apps, My Marketplace Apps, and My ReadWriteMailbox Apps. 6. Click Save changes. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following script: $TargetRoles = @( \"My Custom Apps\", \"My Marketplace Apps\", \"My ReadWriteMailbox Apps\" ) $DefaultPolicy = Get-RoleAssignmentPolicy | Where-Object { $_.IsDefault -eq $true } $Assignments = Get-ManagementRoleAssignment -RoleAssignee $DefaultPolicy.Identity | Where-Object { $_.Role -in $TargetRoles } foreach ($Assignment in $Assignments) { Remove-ManagementRoleAssignment -Identity $Assignment.Identity - Confirm:$false }", + "AuditProcedure": "To audit using the UI: 1. Navigate to Exchange admin center https://admin.exchange.microsoft.com. 2. Expand Roles and select User roles. 3. Select Default Role Assignment Policy. 4. In the properties pane on the right click on Manage permissions. 5. Under Other roles verify that My Custom Apps, My Marketplace Apps, and My ReadWriteMailbox Apps are not checked. Note: As of this release of the Benchmark the manage permissions link no longer displays anything when a user assigned the Global Reader role clicks on it. As an alternative, users assigned the Global Reader directory role can inspect the Roles column or use the PowerShell method to perform the audit. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following script: $RoleList = @( \"My Custom Apps\", \"My Marketplace Apps\", \"My ReadWriteMailbox Apps\" ) $DefaultPolicy = Get-RoleAssignmentPolicy | Where-Object { $_.IsDefault -eq $true } $NonCompliantRoles = $DefaultPolicy.AssignedRoles | Where-Object { $_ -in $RoleList } Write-Host \"Checking Default Role Assignment Policy: $($DefaultPolicy.Name)\" if ($NonCompliantRoles) { \"Non-compliant - the following roles are assigned: \" + ($NonCompliantRoles -join \", \") } else { \"Compliant - no add-in roles are assigned to the default policy.\" } 3. Verify that the output indicates compliance. If My Custom Apps, My Marketplace Apps, or My ReadWriteMailbox Apps are listed, the default policy is non-compliant.", + "AdditionalInformation": "", + "DefaultValue": "UI - My Custom Apps, My Marketplace Apps, and My ReadWriteMailbox Apps are checked. PowerShell - My Custom Apps, My Marketplace Apps, and My ReadWriteMailbox Apps are assigned.", + "References": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/add-ins-for-outlook/specify-who-can-install-and-manage-add-ins?source=recommendations:https://learn.microsoft.com/en-us/exchange/permissions-exo/role-assignment-policies" + } + ] + }, + { + "Id": "6.3.2", + "Description": "Outlook on the web (OWA) mailbox policies include two settings that control personal account integration in Outlook. PersonalAccountsEnabled controls whether users can add personal email accounts (e.g., Outlook.com, Gmail, Yahoo) in the new Outlook for Windows. PersonalAccountCalendarsEnabled controls whether users can connect personal Outlook.com or Google calendars in Outlook on the web. Neither setting applies to classic Outlook for Windows, Outlook for Mac, or Outlook mobile apps. The recommended state for the default OWA Mailbox Policy is: - PersonalAccountsEnabled is set to False - PersonalAccountCalendarsEnabled is set to False", + "Checks": [], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.3 Roles", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Outlook on the web (OWA) mailbox policies include two settings that control personal account integration in Outlook. PersonalAccountsEnabled controls whether users can add personal email accounts (e.g., Outlook.com, Gmail, Yahoo) in the new Outlook for Windows. PersonalAccountCalendarsEnabled controls whether users can connect personal Outlook.com or Google calendars in Outlook on the web. Neither setting applies to classic Outlook for Windows, Outlook for Mac, or Outlook mobile apps. The recommended state for the default OWA Mailbox Policy is: - PersonalAccountsEnabled is set to False - PersonalAccountCalendarsEnabled is set to False", + "RationaleStatement": "Personal email accounts are not subject to corporate security controls such as anti- malware scanning, data loss prevention (DLP), Safe Links, or audit logging. Allowing personal accounts alongside the corporate mailbox enables side-channel data exfiltration (e.g., forwarding sensitive content to a personal inbox) and creates an ingress path for malware and phishing payloads that bypass tenant mail-flow protections.", + "ImpactStatement": "This control does not apply to classic Outlook for Windows, Outlook for Mac, or Outlook mobile apps. Organizations requiring broader coverage should evaluate additional controls like application management policies to restrict personal account usage on those clients. This also does not block users from accessing personal accounts via other email clients or web browsers. Changes to OWA mailbox policies may take up to 60 minutes to take effect. If users previously added personal accounts before this policy was applied, those accounts will be disabled once the policy is detected, and affected users will see a message advising them to remove the personal account from Outlook, which may generate helpdesk inquiries. The audit only applies to the default OWA mailbox policy. Users assigned to a non- default OWA mailbox policy are not covered; optionally, custom policies can be reviewed separately to ensure a level of enforcement beyond the compliance requirements of this control.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following: $DefaultPolicy = Get-OwaMailboxPolicy | Where-Object { $_.IsDefault } Set-OwaMailboxPolicy -Identity $DefaultPolicy.Identity - PersonalAccountsEnabled $false -PersonalAccountCalendarsEnabled $false", + "AuditProcedure": "Note: The default OWA Mailbox Policy is the only policy required for compliance with this control. Other mailbox policies are discretionary and left up to the organization to audit as needed. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following: Get-OwaMailboxPolicy | Where-Object { $_.IsDefault } | Format-List PersonalAccountsEnabled, PersonalAccountCalendarsEnabled 3. Verify the output matches the following: PersonalAccountsEnabled : False PersonalAccountCalendarsEnabled : False", + "AdditionalInformation": "", + "DefaultValue": "- PersonalAccountsEnabled is True. - PersonalAccountCalendarsEnabled is True.", + "References": "https://learn.microsoft.com/en-us/powershell/module/exchangepowershell/set-owamailboxpolicy?view=exchange-ps#-personalaccountsenabled:https://learn.microsoft.com/en-us/microsoft-365-apps/outlook/get-started/supported-account-types#prevent-adding-personal-accounts:https://learn.microsoft.com/en-us/microsoft-365-apps/outlook/manage/policy-management" + } + ] + }, + { + "Id": "6.5.1", + "Description": "Modern authentication in Microsoft 365 enables authentication features like multifactor authentication (MFA) using smart cards, certificate-based authentication (CBA), and third-party SAML identity providers. When you enable modern authentication in Exchange Online, Outlook 2016 and Outlook 2013 use modern authentication to log in to Microsoft 365 mailboxes. When you disable modern authentication in Exchange Online, Outlook 2016 and Outlook 2013 use basic authentication to log in to Microsoft 365 mailboxes. When users initially configure certain email clients, like Outlook 2013 and Outlook 2016, they may be required to authenticate using enhanced authentication mechanisms, such as multifactor authentication. Other Outlook clients that are available in Microsoft 365 (for example, Outlook Mobile and Outlook for Mac 2016) always use modern authentication to log in to Microsoft 365 mailboxes.", + "Checks": [ + "exchange_organization_modern_authentication_enabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.5 Settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Modern authentication in Microsoft 365 enables authentication features like multifactor authentication (MFA) using smart cards, certificate-based authentication (CBA), and third-party SAML identity providers. When you enable modern authentication in Exchange Online, Outlook 2016 and Outlook 2013 use modern authentication to log in to Microsoft 365 mailboxes. When you disable modern authentication in Exchange Online, Outlook 2016 and Outlook 2013 use basic authentication to log in to Microsoft 365 mailboxes. When users initially configure certain email clients, like Outlook 2013 and Outlook 2016, they may be required to authenticate using enhanced authentication mechanisms, such as multifactor authentication. Other Outlook clients that are available in Microsoft 365 (for example, Outlook Mobile and Outlook for Mac 2016) always use modern authentication to log in to Microsoft 365 mailboxes.", + "RationaleStatement": "Strong authentication controls, such as the use of multifactor authentication, may be circumvented if basic authentication is used by Exchange Online email clients such as Outlook 2016 and Outlook 2013. Enabling modern authentication for Exchange Online ensures strong authentication mechanisms are used when establishing sessions between email clients and Exchange Online.", + "ImpactStatement": "Users of older email clients, such as Outlook 2013 and Outlook 2016, will no longer be able to authenticate to Exchange using Basic Authentication, which will necessitate migration to modern authentication practices.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings and select Org Settings. 3. Select Modern authentication. 4. Check Turn on modern authentication for Outlook 2013 for Windows and later (recommended) to enable modern authentication. To remediate using PowerShell: 1. Run the Microsoft Exchange Online PowerShell Module. 2. Connect to Exchange Online using Connect-ExchangeOnline. 3. Run the following PowerShell command: Set-OrganizationConfig -OAuth2ClientProfileEnabled $True", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings and select Org Settings. 3. Select Modern authentication. 4. Verify that Turn on modern authentication for Outlook 2013 for Windows and later (recommended) is checked. To audit using PowerShell: 1. Run the Microsoft Exchange Online PowerShell Module. 2. Connect to Exchange Online using Connect-ExchangeOnline. 3. Run the following PowerShell command: Get-OrganizationConfig | Format-Table -Auto Name, OAuth* 4. Verify that OAuth2ClientProfileEnabled is True.", + "AdditionalInformation": "", + "DefaultValue": "True", + "References": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/enable-or-disable-modern-authentication-in-exchange-online" + } + ] + }, + { + "Id": "6.5.2", + "Description": "MailTips are informative messages displayed to users while they're composing a message. While a new message is open and being composed, Exchange analyzes the message (including recipients). If a potential problem is detected, the user is notified with a MailTip prior to sending the message. Using the information in the MailTip, the user can adjust the message to avoid undesirable situations or non-delivery reports (also known as NDRs or bounce messages).", + "Checks": [ + "exchange_organization_mailtips_enabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.5 Settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "MailTips are informative messages displayed to users while they're composing a message. While a new message is open and being composed, Exchange analyzes the message (including recipients). If a potential problem is detected, the user is notified with a MailTip prior to sending the message. Using the information in the MailTip, the user can adjust the message to avoid undesirable situations or non-delivery reports (also known as NDRs or bounce messages).", + "RationaleStatement": "Setting up MailTips gives a visual aid to users when they send emails to large groups of recipients or send emails to recipients not within the tenant.", + "ImpactStatement": "Not applicable.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: $TipsParams = @{ MailTipsAllTipsEnabled = $true MailTipsExternalRecipientsTipsEnabled = $true MailTipsGroupMetricsEnabled = $true MailTipsLargeAudienceThreshold = '25' } Set-OrganizationConfig @TipsParams", + "AuditProcedure": "To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-OrganizationConfig | fl MailTips* 3. Verify the values for MailTipsAllTipsEnabled, MailTipsExternalRecipientsTipsEnabled, and MailTipsGroupMetricsEnabled are set to True and MailTipsLargeAudienceThreshold is set to an acceptable value; 25 is the default value.", + "AdditionalInformation": "", + "DefaultValue": "MailTipsAllTipsEnabled: True MailTipsExternalRecipientsTipsEnabled: False MailTipsGroupMetricsEnabled: True MailTipsLargeAudienceThreshold: 25", + "References": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/mailtips/mailtips:https://learn.microsoft.com/en-us/powershell/module/exchange/set-organizationconfig?view=exchange-ps" + } + ], + "ConfigRequirements": [ + { + "Check": "exchange_organization_mailtips_enabled", + "ConfigKey": "recommended_mailtips_large_audience_threshold", + "Operator": "lte", + "Value": 25 + } + ] + }, + { + "Id": "6.5.3", + "Description": "This setting allows users to open certain external files while working in Outlook on the web. If allowed, keep in mind that Microsoft doesn't control the use terms or privacy policies of those third-party services. Ensure AdditionalStorageProvidersAvailable is restricted on the default OWA policy.", + "Checks": [ + "exchange_mailbox_policy_additional_storage_restricted" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.5 Settings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "This setting allows users to open certain external files while working in Outlook on the web. If allowed, keep in mind that Microsoft doesn't control the use terms or privacy policies of those third-party services. Ensure AdditionalStorageProvidersAvailable is restricted on the default OWA policy.", + "RationaleStatement": "By default, additional storage providers are allowed in Office on the Web (such as Box, Dropbox, Facebook, Google Drive, OneDrive Personal, etc.). This could lead to information leakage and additional risk of infection from organizational non-trusted storage providers. Restricting this will inherently reduce risk as it will narrow opportunities for infection and data leakage.", + "ImpactStatement": "The impact associated with this change is highly dependent upon current practices in the tenant. If users do not use other storage providers, then minimal impact is likely. However, if users do regularly utilize providers outside of the tenant this will affect their ability to continue to do so.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-OwaMailboxPolicy -Identity OwaMailboxPolicy-Default - AdditionalStorageProvidersAvailable $false", + "AuditProcedure": "To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command to audit the default OWA policy: Get-OwaMailboxPolicy -Identity OwaMailboxPolicy-Default | fl AdditionalStorageProvidersAvailable 3. Verify that AdditionalStorageProvidersAvailable is False.", + "AdditionalInformation": "", + "DefaultValue": "AdditionalStorageProvidersAvailable : True", + "References": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-owamailboxpolicy?view=exchange-ps:https://support.microsoft.com/en-us/topic/3rd-party-cloud-storage-services-supported-by-office-apps-fce12782-eccc-4cf5-8f4b-d1ebec513f72" + } + ] + }, + { + "Id": "6.5.4", + "Description": "This setting enables or disables authenticated client SMTP submission (SMTP AUTH) at an organization level in Exchange Online. The recommended state is Turn off SMTP AUTH protocol for your organization (checked).", + "Checks": [ + "exchange_transport_config_smtp_auth_disabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.5 Settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting enables or disables authenticated client SMTP submission (SMTP AUTH) at an organization level in Exchange Online. The recommended state is Turn off SMTP AUTH protocol for your organization (checked).", + "RationaleStatement": "SMTP AUTH is a legacy protocol. Disabling it at the organization level supports the principle of least functionality and serves to further back additional controls that block legacy protocols, such as in Conditional Access. Virtually all modern email clients that connect to Exchange Online mailboxes in Microsoft 365 can do so without using SMTP AUTH.", + "ImpactStatement": "This enforces the default behavior, so no impact is expected unless the organization is using it globally. A per-mailbox setting exists that overrides the tenant-wide setting, allowing an individual mailbox SMTP AUTH capability for special cases.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Exchange admin center https://admin.exchange.microsoft.com. 2. Expand Settings and select Mail flow. 3. Check Turn off SMTP AUTH protocol for your organization to disable the protocol. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-TransportConfig -SmtpClientAuthenticationDisabled $true", + "AuditProcedure": "To audit using the UI: 1. Navigate to Exchange admin center https://admin.exchange.microsoft.com. 2. Expand Settings and select Mail flow. 3. Ensure Turn off SMTP AUTH protocol for your organization is checked. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-TransportConfig | Format-List SmtpClientAuthenticationDisabled 3. Verify that the value returned is True.", + "AdditionalInformation": "", + "DefaultValue": "SmtpClientAuthenticationDisabled : True", + "References": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission" + } + ] + }, + { + "Id": "6.5.5", + "Description": "Direct Send is a method used to send emails directly to an Exchange Online customer's hosted mailboxes from on-premises devices, applications, or third-party cloud services using the customer's own accepted domain. This method does not require any form of authentication because, by its nature, it mimics incoming anonymous emails from the internet, apart from the sender domain. The recommended state is to configure RejectDirectSend to True.", + "Checks": [], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.5 Settings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Direct Send is a method used to send emails directly to an Exchange Online customer's hosted mailboxes from on-premises devices, applications, or third-party cloud services using the customer's own accepted domain. This method does not require any form of authentication because, by its nature, it mimics incoming anonymous emails from the internet, apart from the sender domain. The recommended state is to configure RejectDirectSend to True.", + "RationaleStatement": "Direct Send allows devices and applications to transmit unauthenticated email directly to Exchange Online. While this method may support legacy systems such as printers or scanners, it introduces significant security risks: - Unauthenticated Email Delivery: Direct Send does not require authentication, making it an attractive vector for threat actors to deliver spoofed or malicious emails that appear to originate from trusted internal sources. - Phishing and Spoofing Risks: Because these emails bypass standard authentication mechanisms, they can easily impersonate internal users or services, increasing the likelihood of successful phishing attacks. - Lack of Visibility and Control: Emails sent via Direct Send may not be subject to the same security policies, logging, or filtering as authenticated traffic, reducing the organization's ability to monitor and respond to threats effectively. Threat research from Varonis has shown that attackers are actively exploiting Direct Send to impersonate internal accounts and distribute malicious content without needing to compromise any credentials. These campaigns have successfully targeted organizations by leveraging predictable infrastructure and public user data to craft convincing phishing emails. Because these messages originate from outside the tenant but appear internal, they often evade detection and filtering mechanisms.", + "ImpactStatement": "Per Microsoft, there is a forwarding scenario that could be affected by this feature. It is possible that someone in your organization sends a message to a 3rd party and they in turn forward it to another mailbox in your organization. If the 3rd party's email provider does not support Sender Rewriting Scheme (SRS), the message will return with the original sender's address. Prior to this feature being enabled, those messages will already be punished by SPF failing but could still end up in inboxes. Enabling the Reject Direct Send feature without a partner mail flow connector being set up will lead to these messages being rejected outright.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-OrganizationConfig -RejectDirectSend $true", + "AuditProcedure": "To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-OrganizationConfig | fl RejectDirectSend 3. Verify that the value returned for RejectDirectSend is True.", + "AdditionalInformation": "", + "DefaultValue": "RejectDirectSend : False", + "References": "https://techcommunity.microsoft.com/blog/exchange/introducing-more-control-over-direct-send-in-exchange-online/4408790?WT.mc_id=M365-MVP-9501:https://techcommunity.microsoft.com/blog/exchange/direct-send-vs-sending-directly-to-an-exchange-online-tenant/4439865:https://learn.microsoft.com/en-us/powershell/module/exchangepowershell/set-organizationconfig?view=exchange-ps:https://www.varonis.com/blog/direct-send-exploit:https://techcommunity.microsoft.com/discussions/microsoft-365/disable-direct-send-in-exchange-online-to-mitigate-ongoing-phishing-threats/4434649" + } + ] + }, + { + "Id": "7.2.1", + "Description": "Modern authentication in Microsoft 365 enables authentication features like multifactor authentication (MFA) using smart cards, certificate-based authentication (CBA), and third-party SAML identity providers.", + "Checks": [ + "sharepoint_modern_authentication_required" + ], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Modern authentication in Microsoft 365 enables authentication features like multifactor authentication (MFA) using smart cards, certificate-based authentication (CBA), and third-party SAML identity providers.", + "RationaleStatement": "Strong authentication controls, such as the use of multifactor authentication, may be circumvented if basic authentication is used by SharePoint applications. Requiring modern authentication for SharePoint applications ensures strong authentication mechanisms are used when establishing sessions between these applications, SharePoint, and connecting users.", + "ImpactStatement": "Implementation of modern authentication for SharePoint will require users to authenticate to SharePoint using modern authentication. This may cause a minor impact to typical user behavior. This may also prevent third-party apps from accessing SharePoint Online resources. Also, this will also block apps using the SharePointOnlineCredentials class to access SharePoint Online resources.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint. 2. Expand Policies and select Access control. 3. Select Apps that don't use modern authentication. 4. Select the radio button for Block access. 5. Click Save. To remediate using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService -Url https://tenant-admin.sharepoint.com replacing tenant with your value. 2. Run the following SharePoint Online PowerShell command: Set-SPOTenant -LegacyAuthProtocolsEnabled $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint. 2. Expand Policies and select Access control. 3. Select Apps that don't use modern authentication and ensure that it is set to Block access. To audit using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService -Url https://tenant-admin.sharepoint.com replacing tenant with your value. 2. Run the following SharePoint Online PowerShell command: Get-SPOTenant | ft LegacyAuthProtocolsEnabled 3. Verify that the returned value is False.", + "AdditionalInformation": "", + "DefaultValue": "True (Apps that don't use modern authentication are allowed)", + "References": "https://learn.microsoft.com/en-us/powershell/module/sharepoint-online/set-spotenant?view=sharepoint-ps" + } + ] + }, + { + "Id": "7.2.2", + "Description": "Entra ID B2B provides authentication and management of guests. Authentication happens via one-time passcode when they don't already have a work or school account or a Microsoft account. Integration with SharePoint and OneDrive allows for more granular control of how guest user accounts are managed in the organization's AAD, unifying a similar guest experience already deployed in other Microsoft 365 services such as Teams. Note: Global Reader role currently can't access SharePoint using PowerShell.", + "Checks": [], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Entra ID B2B provides authentication and management of guests. Authentication happens via one-time passcode when they don't already have a work or school account or a Microsoft account. Integration with SharePoint and OneDrive allows for more granular control of how guest user accounts are managed in the organization's AAD, unifying a similar guest experience already deployed in other Microsoft 365 services such as Teams. Note: Global Reader role currently can't access SharePoint using PowerShell.", + "RationaleStatement": "External users assigned guest accounts will be subject to Entra ID access policies, such as multi-factor authentication. This provides a way to manage guest identities and control access to SharePoint and OneDrive resources. Without this integration, files can be shared without account registration, making it more challenging to audit and manage who has access to the organization's data.", + "ImpactStatement": "After enabling Microsoft Entra B2B integration, external users attempting to access previously shared links (One Time Passcode) will encounter access issues. They receive error 'This organization has updated its guest access settings'. To restore access, your users need to reshare files/folders/sites to external users.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService 2. Run the following command: Set-SPOTenant -EnableAzureADB2BIntegration $true", + "AuditProcedure": "To audit using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService 2. Run the following command: Get-SPOTenant | ft EnableAzureADB2BIntegration 3. Verify that the returned value is True.", + "AdditionalInformation": "", + "DefaultValue": "False", + "References": "https://learn.microsoft.com/en-us/sharepoint/sharepoint-azureb2b-integration#enabling-the-integration:https://learn.microsoft.com/en-us/entra/external-id/what-is-b2b:https://learn.microsoft.com/en-us/powershell/module/sharepoint-online/set-spotenant?view=sharepoint-ps" + } + ] + }, + { + "Id": "7.2.3", + "Description": "The external sharing settings govern sharing for the organization overall. Each site has its own sharing setting that can be set independently, though it must be at the same or more restrictive setting as the organization. The new and existing guests option requires people who have received invitations to sign in with their work or school account (if their organization uses Microsoft 365) or a Microsoft account, or to provide a code to verify their identity. Users can share with guests already in your organization's directory, and they can send invitations to people who will be added to the directory if they sign in. The recommended state is New and existing guests or less permissive.", + "Checks": [ + "sharepoint_external_sharing_restricted" + ], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "The external sharing settings govern sharing for the organization overall. Each site has its own sharing setting that can be set independently, though it must be at the same or more restrictive setting as the organization. The new and existing guests option requires people who have received invitations to sign in with their work or school account (if their organization uses Microsoft 365) or a Microsoft account, or to provide a code to verify their identity. Users can share with guests already in your organization's directory, and they can send invitations to people who will be added to the directory if they sign in. The recommended state is New and existing guests or less permissive.", + "RationaleStatement": "Forcing guest authentication on the organization's tenant enables the implementation of controls and oversight over external file sharing. When a guest is registered with the organization, they now have an identity which can be accounted for. This identity can also have other restrictions applied to it through group membership and conditional access rules.", + "ImpactStatement": "When using B2B integration, Entra ID external collaboration settings, such as guest invite settings and collaboration restrictions apply.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Locate the External sharing section. 4. Under SharePoint, move the slider bar to New and existing guests or a less permissive level. o OneDrive will also be moved to the same level and can never be more permissive than SharePoint. To remediate using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet to establish the minimum recommended state: Set-SPOTenant -SharingCapability ExternalUserSharingOnly Note: Other acceptable values for this parameter that are more restrictive include: Disabled and ExistingExternalUserSharingOnly.", + "AuditProcedure": "To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Locate the External sharing section. 4. Under SharePoint, verify that the slider bar is set to New and existing guests or a less permissive level. To audit using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet: Get-SPOTenant | fl SharingCapability 3. Verify that SharingCapability is set to one of the following values: o ExternalUserSharingOnly o ExistingExternalUserSharingOnly o Disabled", + "AdditionalInformation": "", + "DefaultValue": "Anyone (ExternalUserAndGuestSharing)", + "References": "https://learn.microsoft.com/en-us/sharepoint/turn-external-sharing-on-or-off:https://learn.microsoft.com/en-us/powershell/module/sharepoint-online/set-spotenant?view=sharepoint-ps" + } + ] + }, + { + "Id": "7.2.4", + "Description": "This setting governs the global permissiveness of OneDrive content sharing in the organization. OneDrive content sharing can be restricted independent of SharePoint but can never be more permissive than the level established with SharePoint. The recommended state is Only people in your organization.", + "Checks": [], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "This setting governs the global permissiveness of OneDrive content sharing in the organization. OneDrive content sharing can be restricted independent of SharePoint but can never be more permissive than the level established with SharePoint. The recommended state is Only people in your organization.", + "RationaleStatement": "OneDrive, designed for end-user cloud storage, inherently provides less oversight and control compared to SharePoint, which often involves additional content overseers or site administrators. This autonomy can lead to potential risks such as inadvertent sharing of privileged information by end users. Restricting external OneDrive sharing will require users to transfer content to SharePoint folders first which have those tighter controls.", + "ImpactStatement": "Users will be required to take additional steps to share OneDrive content or use other official channels.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Locate the External sharing section. 4. Under OneDrive, set the slider bar to Only people in your organization. To remediate using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet: Set-SPOTenant -OneDriveSharingCapability Disabled Alternative remediation method using PowerShell: 1. Connect to SharePoint Online. 2. Run one of the following: # Replace [tenant] with your tenant id Set-SPOSite -Identity https://[tenant]-my.sharepoint.com/ -SharingCapability Disabled # Or run this to filter to the specific site without supplying the tenant name. $OneDriveSite = Get-SPOSite -Filter { Url -like \"*-my.sharepoint.com/\" } Set-SPOSite -Identity $OneDriveSite -SharingCapability Disabled", + "AuditProcedure": "To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Locate the External sharing section. 4. Under OneDrive, verify that the slider bar is set to Only people in your organization. To audit using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet: Get-SPOTenant | fl OneDriveSharingCapability 3. Verify that the returned value is Disabled. Alternative audit method using PowerShell: 1. Connect to SharePoint Online. 2. Use one of the following methods: # Replace [tenant] with your tenant id Get-SPOSite -Identity https://[tenant]-my.sharepoint.com/ | fl Url,SharingCapability # Or run this to filter to the specific site without supplying the tenant name. $OneDriveSite = Get-SPOSite -Filter { Url -like \"*-my.sharepoint.com/\" } Get-SPOSite -Identity $OneDriveSite | fl Url,SharingCapability 2. Verify that the returned value for SharingCapability is Disabled Note: As of March 2024, using Get-SPOSite with Where-Object or filtering against the entire site and then returning the SharingCapability parameter can result in a different value as opposed to running the cmdlet specifically against the OneDrive specific site using the -Identity switch as shown in the example. Note 2: The parameter OneDriveSharingCapability may not be yet fully available in all tenants. It is demonstrated in official Microsoft documentation as linked in the references section but not in the Set-SPOTenant cmdlet itself. If the parameter is unavailable, then either use the UI method or alternative PowerShell audit method.", + "AdditionalInformation": "", + "DefaultValue": "Anyone (ExternalUserAndGuestSharing)", + "References": "https://learn.microsoft.com/en-us/powershell/module/sharepoint-online/set-spotenant?view=sharepoint-ps#-onedrivesharingcapability" + } + ] + }, + { + "Id": "7.2.5", + "Description": "SharePoint gives users the ability to share files, folders, and site collections. Internal users can share with external collaborators, and with the right permissions could share to other external parties.", + "Checks": [ + "sharepoint_guest_sharing_restricted" + ], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "SharePoint gives users the ability to share files, folders, and site collections. Internal users can share with external collaborators, and with the right permissions could share to other external parties.", + "RationaleStatement": "Sharing and collaboration are key; however, file, folder, or site collection owners should have the authority over what external users get shared with to prevent unauthorized disclosures of information.", + "ImpactStatement": "The impact associated with this change is highly dependent upon current practices. If users do not regularly share with external parties, then minimal impact is likely. However, if users do regularly share with guests/externally, minimum impacts could occur as those external users will be unable to 're-share' content.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Expand More external sharing settings, uncheck Allow guests to share items they don't own. 4. Click Save. To remediate using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following SharePoint Online PowerShell command: Set-SPOTenant -PreventExternalUsersFromResharing $True", + "AuditProcedure": "To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Expand More external sharing settings, verify that Allow guests to share items they don't own is unchecked. To audit using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following SharePoint Online PowerShell command: Get-SPOTenant | ft PreventExternalUsersFromResharing 3. Verify that the returned value is True.", + "AdditionalInformation": "", + "DefaultValue": "Checked (False)", + "References": "https://learn.microsoft.com/en-us/sharepoint/turn-external-sharing-on-or-off:https://learn.microsoft.com/en-us/sharepoint/external-sharing-overview" + } + ] + }, + { + "Id": "7.2.6", + "Description": "The external sharing features of SharePoint and OneDrive let users in the organization share content with people outside the organization (such as partners, vendors, clients, or customers). It can also be used to share between licensed users on multiple Microsoft 365 subscriptions if your organization has more than one subscription. The recommended state is Limit external sharing by domain > Allow only specific domains", + "Checks": [ + "sharepoint_external_sharing_managed" + ], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "The external sharing features of SharePoint and OneDrive let users in the organization share content with people outside the organization (such as partners, vendors, clients, or customers). It can also be used to share between licensed users on multiple Microsoft 365 subscriptions if your organization has more than one subscription. The recommended state is Limit external sharing by domain > Allow only specific domains", + "RationaleStatement": "Attackers will often attempt to expose sensitive information to external entities through sharing, and restricting the domains that users can share documents with will reduce that surface area.", + "ImpactStatement": "Users will be unable to initiate new shares with parties whose domains are not on the approved allowlist, which may require administrative action before collaboration with new partners or vendors can begin. Administrators must keep the allowlist current to reflect active business relationships; an outdated list can block legitimate sharing and generate support requests. Note that existing shares to domains not on the allowlist are not revoked when this setting is configured.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint. 2. Expand Policies > Sharing. 3. Expand More external sharing settings and check Limit external sharing by domain. 4. Select Add domains, choose Allow only specific domains, enter the list of approved domain names, and select Done. 5. Click Save at the bottom of the page. To remediate using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService. 2. Run the following PowerShell command: Set-SPOTenant -SharingDomainRestrictionMode AllowList - SharingAllowedDomainList \"domain1.com domain2.com\"", + "AuditProcedure": "Note: If the SharePoint external sharing slider is set to Only people in your organization, this recommendation is compliant regardless of the configured value for Limit external sharing by domain. To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Locate the External sharing section. 4. If the SharePoint slider is set to Only people in your organization, this recommendation is compliant. 5. Otherwise, expand More external sharing settings and confirm that Limit external sharing by domain is checked. 6. Verify that Allow only specific domains is selected and that domains listed are approved by the organization. To audit using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService. 2. Run the following PowerShell command: Get-SPOTenant | fl SharingCapability,SharingDomainRestrictionMode,SharingAllowedDomainList 3. Verify that one of the following conditions is true: o SharingCapability is Disabled, OR o SharingDomainRestrictionMode is AllowList and SharingAllowedDomainList contains domains trusted by the organization for external sharing.", + "AdditionalInformation": "", + "DefaultValue": "Limit external sharing by domain is unchecked SharingDomainRestrictionMode: None SharingAllowedDomainList: (empty)", + "References": "https://learn.microsoft.com/en-us/sharepoint/turn-external-sharing-on-or-off?WT.mc_id=365AdminCSH_spo#more-external-sharing-settings" + } + ] + }, + { + "Id": "7.2.7", + "Description": "This setting sets the default link type that a user will see when sharing content in OneDrive or SharePoint. It does not restrict or exclude any other options. The recommended state is Specific people (only the people the user specifies) or Only people in your organization.", + "Checks": [], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting sets the default link type that a user will see when sharing content in OneDrive or SharePoint. It does not restrict or exclude any other options. The recommended state is Specific people (only the people the user specifies) or Only people in your organization.", + "RationaleStatement": "By defaulting to specific people, the user will first need to consider whether or not the content being shared should be accessible by the entire organization versus select individuals. This aids in reinforcing the concept of least privilege.", + "ImpactStatement": "Changing the default sharing link type influences the user experience when sharing files and folders in SharePoint and OneDrive. The configured default option will appear pre- selected in the sharing dialog, guiding users toward the organization's preferred sharing method.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Scroll to File and folder links. 4. Set Choose the type of link that's selected by default when users share files and folders in SharePoint and OneDrive to Specific people (only the people the user specifies) or Only people in your organization. To remediate using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService. 2. Run one of the following PowerShell commands depending on the desired compliant state: To set the default sharing link to specific people: Set-SPOTenant -DefaultSharingLinkType Direct To set the default sharing link to people in the organization: Set-SPOTenant -DefaultSharingLinkType Internal", + "AuditProcedure": "To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Scroll to File and folder links. 4. Verify that the setting Choose the type of link that's selected by default when users share files and folders in SharePoint and OneDrive is set to Specific people (only the people the user specifies) or Only people in your organization. To audit using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService. 2. Run the following PowerShell command: Get-SPOTenant | fl DefaultSharingLinkType 3. Verify that the returned value is Direct or Internal.", + "AdditionalInformation": "", + "DefaultValue": "Only people in your organization (Internal)", + "References": "https://learn.microsoft.com/en-us/powershell/module/sharepoint-online/set-spotenant?view=sharepoint-ps" + } + ] + }, + { + "Id": "7.2.8", + "Description": "External sharing of content can be restricted to specific security groups. This setting is global, applies to sharing in both SharePoint and OneDrive and cannot be set at the site level in SharePoint.", + "Checks": [], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "External sharing of content can be restricted to specific security groups. This setting is global, applies to sharing in both SharePoint and OneDrive and cannot be set at the site level in SharePoint.", + "RationaleStatement": "Without restricting external sharing to designated security groups, any user in the organization can share SharePoint or OneDrive content with external recipients. A compromised or insider-threat account can exfiltrate sensitive data by sharing files externally without additional authorization controls. Limiting external sharing to members of specific Entra ID security groups ensures that only reviewed and authorized users have this capability, reducing the attack surface for data exfiltration through sharing.", + "ImpactStatement": "Users who are not members of the designated security groups will lose the ability to create new external shares or invite new external guests. Existing sharing links they previously established will remain active for current recipients. Organizations should ensure the security groups are populated with appropriate members before enabling this setting to avoid inadvertently blocking all external sharing. Helpdesk volume may increase as users in non-designated groups encounter sharing restrictions.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Scroll to and expand More external sharing settings. 4. Set the following: o Check Allow only users in specific security groups to share externally o Click Manage security groups, then add at least one security group authorized for external sharing. To remediate using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService. 2. Run the following command, replacing with the GUID of the security group to be authorized for external sharing: Set-SPOTenant -WhoCanShareAuthenticatedGuestAllowList \"\" Note: To authorize multiple security groups, provide a comma-delimited list of Object IDs: \"\",\"\". Note: Users in the designated security groups must also be permitted to invite guests in Microsoft Entra. Verify this at Identity > External Identities > External collaboration settings.", + "AuditProcedure": "To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Locate the External sharing section. 4. If the SharePoint slider is set to Only people in your organization, this recommendation is compliant. 5. Otherwise, scroll to and expand More external sharing settings. 6. Verify the following: o Allow only users in specific security groups to share externally is checked o Manage security groups contains at least one security group. To audit using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService. 2. Run the following PowerShell command: Get-SPOTenant | fl SharingCapability, WhoCanShareAuthenticatedGuestAllowList 3. Verify the output using the following logic: o If SharingCapability is Disabled, the recommendation is compliant regardless of the value of WhoCanShareAuthenticatedGuestAllowList. o Otherwise, verify that WhoCanShareAuthenticatedGuestAllowList contains at least one security group GUID. If the value is empty or $null, the recommendation is not compliant.", + "AdditionalInformation": "", + "DefaultValue": "By default, this restriction is not in place, allowing any user in the organization to share content externally, subject only to the top-level sharing slider.", + "References": "https://learn.microsoft.com/en-us/sharepoint/manage-security-groups:https://learn.microsoft.com/en-us/powershell/module/microsoft.online.sharepoint.powershell/set-spotenant?view=sharepoint-ps" + } + ] + }, + { + "Id": "7.2.9", + "Description": "This policy setting configures the expiration time for each guest that is invited to the SharePoint site or with whom users share individual files and folders with. Expiration can be a number 30 to 730. The recommended state is 30.", + "Checks": [], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This policy setting configures the expiration time for each guest that is invited to the SharePoint site or with whom users share individual files and folders with. Expiration can be a number 30 to 730. The recommended state is 30.", + "RationaleStatement": "This setting ensures that guests who no longer need access to the site or link no longer have access after a set period of time. Allowing guest access for an indefinite amount of time could lead to loss of data confidentiality and oversight. Note: Guest membership applies at the Microsoft 365 group level. Guests who have permission to view a SharePoint site or use a sharing link may also have access to a Microsoft Teams team or security group.", + "ImpactStatement": "Site collection administrators will have to renew access to guests who still need access after 30 days. They will receive an e-mail notification once per week about guest access that is about to expire. Note: The guest expiration policy only applies to guests who use sharing links or guests who have direct permissions to a SharePoint site after the guest policy is enabled. The guest policy does not apply to guest users that have pre-existing permissions or access through a sharing link before the guest expiration policy is applied.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Scroll to and expand More external sharing settings. 4. Set Guest access to a site or OneDrive will expire automatically after this many days to 30 To remediate using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet: Set-SPOTenant -ExternalUserExpireInDays 30 -ExternalUserExpirationRequired $True", + "AuditProcedure": "To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Scroll to and expand More external sharing settings. 4. Verify that Guest access to a site or OneDrive will expire automatically after this many days is checked and set to 30. To audit using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet: Get-SPOTenant | fl ExternalUserExpirationRequired,ExternalUserExpireInDays 3. Verify the following values are returned: o ExternalUserExpirationRequired is True. o ExternalUserExpireInDays is 30.", + "AdditionalInformation": "", + "DefaultValue": "ExternalUserExpirationRequired $false ExternalUserExpireInDays 60 days", + "References": "https://learn.microsoft.com/en-us/sharepoint/turn-external-sharing-on-or-off#change-the-organization-level-external-sharing-setting:https://learn.microsoft.com/en-us/microsoft-365/community/sharepoint-security-a-team-effort" + } + ] + }, + { + "Id": "7.2.10", + "Description": "This setting configures if guests who use a verification code to access the site or links are required to reauthenticate after a set number of days. The recommended state is 15 or less.", + "Checks": [], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting configures if guests who use a verification code to access the site or links are required to reauthenticate after a set number of days. The recommended state is 15 or less.", + "RationaleStatement": "By increasing the frequency of times guests need to reauthenticate this ensures guest user access to data is not prolonged beyond an acceptable amount of time.", + "ImpactStatement": "Guests who use Microsoft 365 in their organization can sign in using their work or school account to access the site or document. After the one-time passcode for verification has been entered for the first time, guests will authenticate with their work or school account and have a guest account created in the host's organization. Note: If OneDrive and SharePoint integration with Entra ID B2B is enabled as per the CIS Benchmark the one-time-passcode experience will be replaced. Please visit Secure external sharing in SharePoint - SharePoint in Microsoft 365 | Microsoft Learn for more information.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Scroll to and expand More external sharing settings. 4. Set People who use a verification code must reauthenticate after this many days to 15 or less. To remediate using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet: Set-SPOTenant -EmailAttestationRequired $true -EmailAttestationReAuthDays 15", + "AuditProcedure": "To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Scroll to and expand More external sharing settings. 4. Verify that People who use a verification code must reauthenticate after this many days is set to 15 or less. To audit using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet: Get-SPOTenant | fl EmailAttestationRequired,EmailAttestationReAuthDays 3. Verify that the following values are returned: o EmailAttestationRequired True o EmailAttestationReAuthDays 15 or less days.", + "AdditionalInformation": "", + "DefaultValue": "EmailAttestationRequired : False EmailAttestationReAuthDays : 30", + "References": "https://learn.microsoft.com/en-us/sharepoint/what-s-new-in-sharing-in-targeted-release:https://learn.microsoft.com/en-us/sharepoint/turn-external-sharing-on-or-off#change-the-organization-level-external-sharing-setting:https://learn.microsoft.com/en-us/entra/external-id/one-time-passcode" + } + ] + }, + { + "Id": "7.2.11", + "Description": "This setting configures the permission that is selected by default for sharing link from a SharePoint site. The recommended state is View.", + "Checks": [], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting configures the permission that is selected by default for sharing link from a SharePoint site. The recommended state is View.", + "RationaleStatement": "Setting the view permission as the default ensures that users must deliberately select the edit permission when sharing a link. This approach reduces the risk of unintentionally granting edit privileges to a resource that only requires read access, supporting the principle of least privilege.", + "ImpactStatement": "Not applicable.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Scroll to File and folder links. 4. Set Choose the permission that's selected by default for sharing links to View. To remediate using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet: Set-SPOTenant -DefaultLinkPermission View", + "AuditProcedure": "To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Scroll to File and folder links. 4. Verify that Choose the permission that's selected by default for sharing links is set to View. To audit using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet: Get-SPOTenant | fl DefaultLinkPermission 3. Verify that the returned value is View.", + "AdditionalInformation": "", + "DefaultValue": "DefaultLinkPermission : Edit", + "References": "https://learn.microsoft.com/en-us/sharepoint/turn-external-sharing-on-or-off#file-and-folder-links" + } + ] + }, + { + "Id": "7.3.1", + "Description": "By default, SharePoint online allows files that Defender for Office 365 has detected as infected to be downloaded.", + "Checks": [], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.3 Settings", + "Profile": "E5 Level 2", + "AssessmentStatus": "Automated", + "Description": "By default, SharePoint online allows files that Defender for Office 365 has detected as infected to be downloaded.", + "RationaleStatement": "Defender for Office 365 for SharePoint, OneDrive, and Microsoft Teams protects your organization from inadvertently sharing malicious files. When an infected file is detected that file is blocked so that no one can open, copy, move, or share it until further actions are taken by the organization's security team.", + "ImpactStatement": "The only potential impact associated with implementation of this setting is potential inconvenience associated with the small percentage of false positive detections that may occur.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService -Url https://tenant-admin.sharepoint.com, replacing \"tenant\" with the appropriate value. 2. Run the following PowerShell command to set the recommended value: Set-SPOTenant -DisallowInfectedFileDownload $true Note: The Global Reader role cannot access SharePoint using PowerShell according to Microsoft. See the reference section for more information.", + "AuditProcedure": "To audit using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService -Url https://tenant-admin.sharepoint.com, replacing \"tenant\" with the appropriate value. 2. Run the following PowerShell command: Get-SPOTenant | Select-Object DisallowInfectedFileDownload 3. Ensure that the DisallowInfectedFileDownload is set to True. Note: According to Microsoft, SharePoint cannot be accessed through PowerShell by users with the Global Reader role. For further information, please refer to the reference section.", + "AdditionalInformation": "", + "DefaultValue": "False", + "References": "https://learn.microsoft.com/en-us/defender-office-365/safe-attachments-for-spo-odfb-teams-configure?view=o365-worldwide:https://learn.microsoft.com/en-us/defender-office-365/anti-malware-protection-for-spo-odfb-teams-about?view=o365-worldwide:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#global-reader" + } + ] + }, + { + "Id": "8.1.1", + "Description": "Microsoft Teams enables collaboration via file sharing. This file sharing is conducted within Teams, using SharePoint Online, by default; however, third-party cloud services are allowed as well. Note: Skype for business is deprecated as of July 31, 2021 although these settings may still be valid for a period of time. See the link in the references section for more information.", + "Checks": [ + "teams_external_file_sharing_restricted" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.1 Teams", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Microsoft Teams enables collaboration via file sharing. This file sharing is conducted within Teams, using SharePoint Online, by default; however, third-party cloud services are allowed as well. Note: Skype for business is deprecated as of July 31, 2021 although these settings may still be valid for a period of time. See the link in the references section for more information.", + "RationaleStatement": "Ensuring that only authorized cloud storage providers are accessible from Teams will help to dissuade the use of non-approved storage providers.", + "ImpactStatement": "The impact associated with this change is highly dependent upon current practices in the tenant. If users do not use other storage providers, then minimal impact is likely. However, if users do regularly utilize providers outside of the tenant this will affect their ability to continue to do so.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Select Settings & policies > Global (Org-wide default) settings. 3. Click Teams to open the Teams settings section. 4. Under files set storages providers to Off unless they have first been authorized by the organization. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following PowerShell command to disable external providers that are not authorized. (the example disables Citrix Files, DropBox, Box, Google Drive and Egnyte) $Params = @{ Identity = 'Global' AllowGoogleDrive = $false AllowShareFile = $false AllowBox = $false AllowDropBox = $false AllowEgnyte = $false } Set-CsTeamsClientConfiguration @Params", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Select Settings & policies > Global (Org-wide default) settings. 3. Click Teams to open the Teams settings section. 4. Under files verify that only organizationally authorized cloud storage options are set to On and all others Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following to verify the recommended state: $Params = @( 'AllowDropbox' 'AllowBox' 'AllowGoogleDrive' 'AllowShareFile' 'AllowEgnyte' ) Get-CsTeamsClientConfiguration -Identity Global | fl $Params 3. Verify that only authorized providers are set to True and all others False.", + "AdditionalInformation": "", + "DefaultValue": "AllowDropBox : True AllowBox : True AllowGoogleDrive : True AllowShareFile : True AllowEgnyte : True", + "References": "https://learn.microsoft.com/en-us/microsoftteams/teams-powershell-managing-teams" + } + ] + }, + { + "Id": "8.1.2", + "Description": "This setting controls whether Teams channels are allowed to receive emails sent to their unique email addresses. When enabled, emails sent to a channel's address will be delivered and appear in the channel's conversation thread; when disabled, the channel will reject incoming emails, preventing them from being posted. The recommended state is Off.", + "Checks": [ + "teams_email_sending_to_channel_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.1 Teams", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting controls whether Teams channels are allowed to receive emails sent to their unique email addresses. When enabled, emails sent to a channel's address will be delivered and appear in the channel's conversation thread; when disabled, the channel will reject incoming emails, preventing them from being posted. The recommended state is Off.", + "RationaleStatement": "Channel email addresses are not under the tenant's domain and organizations do not have control over the security settings for this email address. An attacker could email channels directly if they discover the channel email address.", + "ImpactStatement": "Depending on the organization's adoption, disabling this may disrupt workflows that rely on email-to-channel communication, particularly in environments where email is used to bridge external systems or vendors into Teams. This could include reduced visibility of important updates or alerts that were previously routed into Teams channels via email.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Select Settings & policies > Global (Org-wide default) settings. 3. Click Teams to open the Teams settings section. 4. Under email integration set Users can send emails to a channel email address to Off. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to set the recommended state: Set-CsTeamsClientConfiguration -Identity Global -AllowEmailIntoChannel $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Select Settings & policies > Global (Org-wide default) settings. 3. Click Teams to open the Teams settings section. 4. Under email integration verify that Users can send emails to a channel email address is Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsClientConfiguration -Identity Global | fl AllowEmailIntoChannel 3. Ensure the returned value is False.", + "AdditionalInformation": "", + "DefaultValue": "On (True)", + "References": "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/step-by-step-guides/reducing-attack-surface-in-microsoft-teams?view=o365-worldwide#restricting-channel-email-messages-to-approved-domains:https://learn.microsoft.com/en-us/microsoftteams/settings-policies-reference#email-integration:https://support.microsoft.com/en-us/office/send-an-email-to-a-channel-in-microsoft-teams-d91db004-d9d7-4a47-82e6-fb1b16dfd51e" + } + ] + }, + { + "Id": "8.2.1", + "Description": "This policy controls whether external domains are allowed, blocked or permitted based on an allowlist or denylist. When external domains are allowed, users in your organization can chat, add users to meetings, and use audio video conferencing with users in external organizations. The recommended state is Off on the Global (Org-wide default) policy.", + "Checks": [ + "teams_external_domains_restricted" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.2 Users", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "This policy controls whether external domains are allowed, blocked or permitted based on an allowlist or denylist. When external domains are allowed, users in your organization can chat, add users to meetings, and use audio video conferencing with users in external organizations. The recommended state is Off on the Global (Org-wide default) policy.", + "RationaleStatement": "Unrestricted external federation allows any Teams user from any organization to initiate contact with your users, making them susceptible to social engineering, phishing, and malware delivery via Teams chat. Restricting external domains to an allowlist or blocking them entirely eliminates this unsolicited contact vector. Real-world attacks and exploits delivered via Teams over external access channels include: - DarkGate malware - Social engineering / phishing attacks by \"Midnight Blizzard\" - GIFShell - Username enumeration", + "ImpactStatement": "Restricting external domains will limit users' ability to collaborate with individuals outside the organization unless their domain is explicitly allowlisted or they are invited as a guest in Microsoft Entra ID. Administrators choosing an allowlist approach will incur ongoing overhead to manage approved domains as external collaboration needs evolve. Note: Organizations may create custom external access policies with federation enabled and assign them to specific users or groups requiring external access, while keeping the Global (Org-wide default) policy restrictive.", + "RemediationProcedure": "Note: Configuring this setting at the organization level in Organization settings to either Off, Block all external domains or Allow only specific external domains is also a compliant remediation for this control. To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Open the Policies tab. 4. Click on the Global (Org-wide default) settings policy. 5. Set Manage external domains for this policy to Off. 6. Click Save. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following command to configure the Global (Org-wide default) policy. Set-CsExternalAccessPolicy -Identity Global -EnableFederationAccess $false", + "AuditProcedure": "Note: The focus of this control at a minimum is the Global (Org-wide default) policy. If the organization-wide setting is configured to Allow only specific external domains or Block all external domains, then this is also considered a passing state due to its increased restrictiveness. To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Open the Policies tab. 4. Click on the Global (Org-wide default) settings policy. 5. Verify that Manage external domains for this policy is set to Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following command: Get-CsExternalAccessPolicy -Identity Global 3. Verify that EnableFederationAccess is False. Organization settings: Optional passing state 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Open the Organization settings tab. 4. Verify that Manage external domains for this organization is set to one of the following: o Off o On with Allow or block external domains set to Allow only specific external domains o On with Allow or block external domains set to Block all external domains To audit using PowerShell: 1. Run the following command: Get-CsTenantFederationConfiguration | fl AllowFederatedUsers,AllowedDomains 2. Verify the output meets one of the following compliant conditions: o Off: AllowFederatedUsers is False o Block all external domains: AllowFederatedUsers is True and AllowedDomains is empty o Allow only specific external domains: AllowFederatedUsers is True and AllowedDomains contains only authorized domain names", + "AdditionalInformation": "", + "DefaultValue": "EnableFederationAccess - $True", + "References": "https://learn.microsoft.com/en-us/microsoftteams/trusted-organizations-external-meetings-chat?tabs=organization-settings:https://www.microsoft.com/en-us/security/blog/2023/08/02/midnight-blizzard-conducts-targeted-social-engineering-over-microsoft-teams/:https://www.bitdefender.com/blog/hotforsecurity/gifshell-attack-lets-hackers-create-reverse-shell-through-microsoft-teams-gifs/" + } + ] + }, + { + "Id": "8.2.2", + "Description": "This policy setting controls chats and meetings initiated through the external access channel with unmanaged Teams users (those not managed by an organization, such as Microsoft Teams (free)). This does not govern anonymous meeting join via shared link, which is controlled separately. The recommended state is: People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts set to Off.", + "Checks": [ + "teams_unmanaged_communication_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.2 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This policy setting controls chats and meetings initiated through the external access channel with unmanaged Teams users (those not managed by an organization, such as Microsoft Teams (free)). This does not govern anonymous meeting join via shared link, which is controlled separately. The recommended state is: People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts set to Off.", + "RationaleStatement": "Allowing users to communicate with unmanaged Teams users presents a potential security threat as little effort is required by threat actors to gain access to a trial or free Microsoft Teams account. Real-world attacks and exploits delivered via Teams over external access channels include: - DarkGate malware - Social engineering / Phishing attacks by \"Midnight Blizzard\" - GIFShell - Username enumeration", + "ImpactStatement": "Users will be unable to communicate with Teams users who are not managed by an organization. Organizations may choose to create additional policies for specific groups needing to communicate with unmanaged external users. Note: The settings that govern chats and meetings with external unmanaged Teams users aren't available in GCC, GCC High, or DOD deployments, or in private cloud environments.", + "RemediationProcedure": "Note: Configuring this setting at the organization level in Organization settings to Off is also a compliant remediation for this control. To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Open the Policies tab. 4. Click on the Global (Org-wide default) settings policy. 5. Set People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts to Off. 6. Click Save. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following command: Set-CsExternalAccessPolicy -Identity Global -EnableTeamsConsumerAccess $false", + "AuditProcedure": "Note: The focus of this control at a minimum is the Global (Org-wide default) policy. If the organization-wide setting is configured to Off, then this is also considered a passing state due to its increased restrictiveness. To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Open the Policies tab. 4. Click on the Global (Org-wide default) settings policy. 5. Verify that People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts is set to Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following command: Get-CsExternalAccessPolicy -Identity Global Verify that EnableTeamsConsumerAccess is set to False. Organization settings: Optional passing state 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Open the Organization settings tab. 4. Verify that People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts is set to Off. To audit using PowerShell: 1. Run the following command: Get-CsTenantFederationConfiguration | fl AllowTeamsConsumer 2. Verify that AllowTeamsConsumer is False.", + "AdditionalInformation": "", + "DefaultValue": "- EnableTeamsConsumerAccess (Global policy): True - AllowTeamsConsumer (Organization settings): True", + "References": "https://learn.microsoft.com/en-us/microsoftteams/trusted-organizations-external-meetings-chat?tabs=organization-settings:https://www.microsoft.com/en-us/security/blog/2023/08/02/midnight-blizzard-conducts-targeted-social-engineering-over-microsoft-teams/:https://www.bitdefender.com/blog/hotforsecurity/gifshell-attack-lets-hackers-create-reverse-shell-through-microsoft-teams-gifs/" + } + ] + }, + { + "Id": "8.2.3", + "Description": "This setting prevents external users who are not managed by an organization from initiating contact with users in the protected organization. The recommended state is to uncheck People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts. Note: Disabling this setting is used as an additional stop gap for the parent setting which disables communication with unmanaged Teams users entirely. If an organization chooses to have an exception to Ensure communication with unmanaged Teams users is disabled they can do so while also disabling the ability for the same group of users to initiate contact. Disabling communication entirely will also disable the ability for unmanaged users to initiate contact.", + "Checks": [ + "teams_external_users_cannot_start_conversations" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.2 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting prevents external users who are not managed by an organization from initiating contact with users in the protected organization. The recommended state is to uncheck People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts. Note: Disabling this setting is used as an additional stop gap for the parent setting which disables communication with unmanaged Teams users entirely. If an organization chooses to have an exception to Ensure communication with unmanaged Teams users is disabled they can do so while also disabling the ability for the same group of users to initiate contact. Disabling communication entirely will also disable the ability for unmanaged users to initiate contact.", + "RationaleStatement": "Allowing users to communicate with unmanaged Teams users presents a potential security threat as little effort is required by threat actors to gain access to a trial or free Microsoft Teams account. Real-world attacks and exploits delivered via Teams over external access channels include: - DarkGate malware - Social engineering / Phishing attacks by \"Midnight Blizzard\" - GIFShell - Username enumeration", + "ImpactStatement": "Unmanaged Teams users (those using personal Microsoft accounts or free Teams) will be unable to initiate new chats or meeting invitations with members of the organization. Organization members may still be able to join externally-initiated meetings depending on the configuration of the parent setting. Organizations that need to allow inbound contact from specific external users can assign a custom external access policy to those users that has EnableTeamsConsumerInbound enabled. Note: Chats and meetings with external unmanaged Teams users isn't available in GCC, GCC High, or DOD deployments, or in private cloud environments.", + "RemediationProcedure": "Note: Configuring this setting at the organization level in Organization settings to Off is also a compliant remediation for this control. To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Open the Policies tab. 4. Click on the Global (Org-wide default) settings policy. 5. Locate the parent setting People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts. 6. Uncheck People in my org can join external meetings and receive new chats from users who have unmanaged Microsoft accounts. 7. Click Save. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following command: Set-CsExternalAccessPolicy -Identity Global -EnableTeamsConsumerInbound $false", + "AuditProcedure": "Note: The focus of this control at a minimum is the Global (Org-wide default) policy. If the equivalent organization-wide setting is disabled, then this is also considered a passing state due to its increased restrictiveness. To audit using the UI: Note: If the parent setting People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts is already set to Off then this setting will not be visible in the UI. 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Open the Policies tab. 4. Click on the Global (Org-wide default) settings policy. 5. Locate the parent setting People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts. 6. Verify that People in my org can join external meetings and receive new chats from users who have unmanaged Microsoft accounts is not checked. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following command: Get-CsExternalAccessPolicy -Identity Global Verify that EnableTeamsConsumerInbound is False Organization settings: Optional passing state 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Select the Organization settings tab. 4. Locate the parent setting People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts. 5. Verify that People in my org can join external meetings and receive new chats from users who have unmanaged Microsoft accounts is not checked. To audit using PowerShell: 1. Run the following command: Get-CsTenantFederationConfiguration | fl AllowTeamsConsumerInbound Verify that AllowTeamsConsumerInbound is False", + "AdditionalInformation": "", + "DefaultValue": "- EnableTeamsConsumerInbound (Global policy) : True - AllowTeamsConsumerInbound (Organization settings) : True", + "References": "https://learn.microsoft.com/en-us/microsoftteams/trusted-organizations-external-meetings-chat?tabs=organization-settings:https://www.microsoft.com/en-us/security/blog/2023/08/02/midnight-blizzard-conducts-targeted-social-engineering-over-microsoft-teams/:https://www.bitdefender.com/blog/hotforsecurity/gifshell-attack-lets-hackers-create-reverse-shell-through-microsoft-teams-gifs/" + } + ] + }, + { + "Id": "8.2.4", + "Description": "This setting controls the organization's external access with Teams \"trial-only\" tenants. These are tenants that don't have any purchased seats. When set to Blocked, users from these trial-only tenants aren't able to search and contact your users via chats, Teams calls, and meetings (using the users' authenticated identities) and your users aren't able to reach users in these trial-only tenants. Users from the trial-only tenant are also removed from existing chats. The recommended state for People in my organization can communicate with accounts in trial Teams tenant is Off.", + "Checks": [], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.2 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting controls the organization's external access with Teams \"trial-only\" tenants. These are tenants that don't have any purchased seats. When set to Blocked, users from these trial-only tenants aren't able to search and contact your users via chats, Teams calls, and meetings (using the users' authenticated identities) and your users aren't able to reach users in these trial-only tenants. Users from the trial-only tenant are also removed from existing chats. The recommended state for People in my organization can communicate with accounts in trial Teams tenant is Off.", + "RationaleStatement": "Microsoft introduced this setting as Off by default on July 29, 2024 in order to block attack vectors being exploited by threat actors who have abused trial tenants. Enforcing the default ensures the setting is not reenabled for any reason. Allowing users to communicate with unmanaged Teams users presents a potential security threat as little effort is required by threat actors to gain access to a trial or free Microsoft Teams account. Real-world attacks and exploits delivered via Teams over external access channels include: - DarkGate malware - Social engineering / Phishing attacks by \"Midnight Blizzard\" - GIFShell - Username enumeration", + "ImpactStatement": "Users currently in chat conversations with accounts from trial tenants will be removed from those existing chats when this setting is disabled. Organizations that have established communication with external contacts who are using trial tenants will need to use alternative channels (such as email) until those contacts migrate to a licensed tenant.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Select the Organization settings tab. 4. Set People in my organization can communicate with accounts in trial Teams tenant to Off. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following command: Set-CsTenantFederationConfiguration -ExternalAccessWithTrialTenants \"Blocked\"", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Select the Organization settings tab. 4. Verify that People in my organization can communicate with accounts in trial Teams tenant is set to Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following command: Get-CsTenantFederationConfiguration Verify that ExternalAccessWithTrialTenants is set to Blocked.", + "AdditionalInformation": "", + "DefaultValue": "- Off (UI) - Blocked (PowerShell)", + "References": "https://learn.microsoft.com/en-us/microsoftteams/trusted-organizations-external-meetings-chat?tabs=organization-settings#block-federation-with-teams-trial-only-tenants:https://www.microsoft.com/en-us/security/blog/2023/08/02/midnight-blizzard-conducts-targeted-social-engineering-over-microsoft-teams/:https://www.bitdefender.com/en-us/blog/hotforsecurity/gifshell-attack-lets-hackers-create-reverse-shell-through-microsoft-teams-gifs" + } + ] + }, + { + "Id": "8.4.1", + "Description": "This policy setting controls which class of apps are available for users to install.", + "Checks": [], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.4 Teams apps", + "Profile": "E3 Level 1", + "AssessmentStatus": "Manual", + "Description": "This policy setting controls which class of apps are available for users to install.", + "RationaleStatement": "Allowing users to install third-party or unverified apps poses a potential risk of introducing malicious software to the environment.", + "ImpactStatement": "Users will only be able to install approved classes of apps.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Expand Teams apps and select Manage apps. 3. In the upper right click Actions > Org-wide app settings. 4. For Third-party apps set Let users install and use available apps by default to Off. 5. For Custom apps set Let users install and use available apps by default to Off. 6. For Custom apps set Let users interact with custom apps in preview to Off.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Expand Teams apps and select Manage apps. 3. In the upper right click Actions > Org-wide app settings. 4. For Third-party apps verify Let users install and use available apps by default is Off. 5. For Custom apps verify Let users install and use available apps by default is Off. 6. For Custom apps verify Let users interact with custom apps in preview is Off.", + "AdditionalInformation": "", + "DefaultValue": "- Third-party apps: On - Custom apps: On", + "References": "https://learn.microsoft.com/en-us/microsoftteams/app-centric-management:https://learn.microsoft.com/en-us/defender-office-365/step-by-step-guides/reducing-attack-surface-in-microsoft-teams?view=o365-worldwide#disabling-third-party--custom-apps" + } + ] + }, + { + "Id": "8.5.1", + "Description": "Anonymous users are users whose identity can't be verified. They may be logged in to an organization without a mutual trust relationship or they may not have an account (guest or user). Anonymous participants appear with \"(Unverified)\" appended to their name in meetings. These users could include: - Users who aren't logged in to Teams with a work or school account. - Users from non-trusted organizations (as configured in external access) and from organizations that you trust but which don't trust your organization. When defining trusted organizations for external meetings and chat, ensure both organizations allow each other's domains. Meeting organizers and participants should have user policies that allow external access. These settings prevent attendees from being considered anonymous due to external access settings. For details, see IT Admins - Manage external meetings and chat with people and organizations using Microsoft identities The recommended state is Anonymous users can join a meeting unverified set to Off.", + "Checks": [ + "teams_meeting_anonymous_user_join_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.5 Meetings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Anonymous users are users whose identity can't be verified. They may be logged in to an organization without a mutual trust relationship or they may not have an account (guest or user). Anonymous participants appear with \"(Unverified)\" appended to their name in meetings. These users could include: - Users who aren't logged in to Teams with a work or school account. - Users from non-trusted organizations (as configured in external access) and from organizations that you trust but which don't trust your organization. When defining trusted organizations for external meetings and chat, ensure both organizations allow each other's domains. Meeting organizers and participants should have user policies that allow external access. These settings prevent attendees from being considered anonymous due to external access settings. For details, see IT Admins - Manage external meetings and chat with people and organizations using Microsoft identities The recommended state is Anonymous users can join a meeting unverified set to Off.", + "RationaleStatement": "For meetings that could contain sensitive information, it is best to allow the meeting organizer to vet anyone not directly sent an invite before admitting them to the meeting. This will also prevent the anonymous user from using the meeting link to have meetings at unscheduled times. Note: Those companies that don't normally operate at a Level 2 environment, but do deal with sensitive information, may want to consider this policy setting.", + "ImpactStatement": "Individuals who were not sent or forwarded a meeting invite will not be able to join the meeting automatically.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting join & lobby set Anonymous users can join a meeting unverified to Off. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following command to set the recommended state: Set-CsTeamsMeetingPolicy -Identity Global -AllowAnonymousUsersToJoinMeeting $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting join & lobby verify that Anonymous users can join a meeting unverified is set to Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsMeetingPolicy -Identity Global | fl AllowAnonymousUsersToJoinMeeting 3. Verify that the returned value is False.", + "AdditionalInformation": "", + "DefaultValue": "On (True)", + "References": "https://learn.microsoft.com/en-us/defender-office-365/step-by-step-guides/reducing-attack-surface-in-microsoft-teams?view=o365-worldwide#configure-meeting-settings:https://learn.microsoft.com/en-us/microsoftteams/settings-policies-reference?WT.mc_id=TeamsAdminCenterCSH#meeting-join--lobby:https://learn.microsoft.com/en-us/MicrosoftTeams/configure-meetings-sensitive-protection:https://learn.microsoft.com/en-us/microsoftteams/anonymous-users-in-meetings:https://learn.microsoft.com/en-us/microsoftteams/plan-meetings-external-participants" + } + ] + }, + { + "Id": "8.5.2", + "Description": "This policy setting controls if an anonymous participant can start a Microsoft Teams meeting without someone in attendance. Anonymous users and dial-in callers must wait in the lobby until the meeting is started by someone in the organization or an external user from a trusted organization. Anonymous participants are classified as: - Participants who are not logged in to Teams with a work or school account. - Participants from non-trusted organizations (as configured in external access). - Participants from organizations where there is not mutual trust. Note: This setting only applies when Who can bypass the lobby is set to Everyone. If the anonymous users can join a meeting organization-level setting or meeting policy is Off, this setting only applies to dial-in callers.", + "Checks": [ + "teams_meeting_anonymous_user_start_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.5 Meetings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This policy setting controls if an anonymous participant can start a Microsoft Teams meeting without someone in attendance. Anonymous users and dial-in callers must wait in the lobby until the meeting is started by someone in the organization or an external user from a trusted organization. Anonymous participants are classified as: - Participants who are not logged in to Teams with a work or school account. - Participants from non-trusted organizations (as configured in external access). - Participants from organizations where there is not mutual trust. Note: This setting only applies when Who can bypass the lobby is set to Everyone. If the anonymous users can join a meeting organization-level setting or meeting policy is Off, this setting only applies to dial-in callers.", + "RationaleStatement": "Not allowing anonymous participants to automatically join a meeting reduces the risk of meeting spamming.", + "ImpactStatement": "Anonymous participants will not be able to start a Microsoft Teams meeting.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting join & lobby set Anonymous users and dial-in callers can start a meeting to Off. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to set the recommended state: Set-CsTeamsMeetingPolicy -Identity Global -AllowAnonymousUsersToStartMeeting $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting join & lobby verify that Anonymous users and dial-in callers can start a meeting is set to Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsMeetingPolicy -Identity Global | fl AllowAnonymousUsersToStartMeeting 3. Verify that the returned value is False.", + "AdditionalInformation": "", + "DefaultValue": "Off (False)", + "References": "https://learn.microsoft.com/en-us/microsoftteams/anonymous-users-in-meetings:https://learn.microsoft.com/en-us/microsoftteams/who-can-bypass-meeting-lobby#overview-of-lobby-settings-and-policies" + } + ] + }, + { + "Id": "8.5.3", + "Description": "This policy setting controls who can join a meeting directly and who must wait in the lobby until they're admitted by an organizer, co-organizer, or presenter of the meeting. The recommended state is People who were invited, People in my org or Only organizers and co-organizers.", + "Checks": [ + "teams_meeting_external_lobby_bypass_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.5 Meetings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This policy setting controls who can join a meeting directly and who must wait in the lobby until they're admitted by an organizer, co-organizer, or presenter of the meeting. The recommended state is People who were invited, People in my org or Only organizers and co-organizers.", + "RationaleStatement": "For meetings that could contain sensitive information, it is best to allow the meeting organizer to vet anyone not directly sent an invite before admitting them to the meeting. This will also prevent the anonymous user from using the meeting link to have meetings at unscheduled times.", + "ImpactStatement": "Individuals who are not part of the organization will have to wait in the lobby until they're admitted by an organizer, co-organizer, or presenter of the meeting. Any individual who dials into the meeting regardless of status will also have to wait in the lobby. This includes internal users who are considered unauthenticated when dialing in.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting join & lobby set Who can bypass the lobby to one of the following: o People who were invited o People in my org o Only organizers and co-organizers To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run one of the following PowerShell commands depending on the desired compliant state: To set to People who were invited: Set-CsTeamsMeetingPolicy -Identity Global -AutoAdmittedUsers \"InvitedUsers\" To set to People in my org: Set-CsTeamsMeetingPolicy -Identity Global -AutoAdmittedUsers \"EveryoneInCompanyExcludingGuests\" To set to Only organizers and co-organizers: Set-CsTeamsMeetingPolicy -Identity Global -AutoAdmittedUsers \"OrganizerOnly\"", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting join & lobby verify Who can bypass the lobby is set to one of the following: o People who were invited o People in my org o Only organizers and co-organizers To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsMeetingPolicy -Identity Global | fl AutoAdmittedUsers 3. Verify that the returned value is one of the following strings: o InvitedUsers o EveryoneInCompanyExcludingGuests o OrganizerOnly", + "AdditionalInformation": "", + "DefaultValue": "People in my org and guests (EveryoneInCompany)", + "References": "https://learn.microsoft.com/en-us/microsoftteams/who-can-bypass-meeting-lobby#overview-of-lobby-settings-and-policies:https://learn.microsoft.com/en-us/powershell/module/skype/set-csteamsmeetingpolicy?view=skype-ps" + } + ] + }, + { + "Id": "8.5.4", + "Description": "This policy setting controls if users who dial in by phone can join the meeting directly or must wait in the lobby. Admittance to the meeting from the lobby is authorized by the meeting organizer, co-organizer, or presenter of the meeting.", + "Checks": [ + "teams_meeting_dial_in_lobby_bypass_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.5 Meetings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This policy setting controls if users who dial in by phone can join the meeting directly or must wait in the lobby. Admittance to the meeting from the lobby is authorized by the meeting organizer, co-organizer, or presenter of the meeting.", + "RationaleStatement": "For meetings that could contain sensitive information, it is best to allow the meeting organizer to vet anyone not directly from the organization.", + "ImpactStatement": "Individuals who are dialing in to the meeting must wait in the lobby until a meeting organizer, co-organizer, or presenter admits them.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting join & lobby set People dialing in can bypass the lobby to Off. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to set the recommended state: Set-CsTeamsMeetingPolicy -Identity Global -AllowPSTNUsersToBypassLobby $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting join & lobby verify that People dialing in can bypass the lobby is set to Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsMeetingPolicy -Identity Global | fl AllowPSTNUsersToBypassLobby 3. Verify that the value is False.", + "AdditionalInformation": "", + "DefaultValue": "Off (False)", + "References": "https://learn.microsoft.com/en-us/microsoftteams/who-can-bypass-meeting-lobby#overview-of-lobby-settings-and-policies:https://learn.microsoft.com/en-us/powershell/module/skype/set-csteamsmeetingpolicy?view=skype-ps" + } + ] + }, + { + "Id": "8.5.5", + "Description": "This policy setting controls who has access to read and write chat messages during a meeting.", + "Checks": [ + "teams_meeting_chat_anonymous_users_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.5 Meetings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "This policy setting controls who has access to read and write chat messages during a meeting.", + "RationaleStatement": "Ensuring that only authorized individuals can read and write chat messages during a meeting reduces the risk that a malicious user can inadvertently show content that is not appropriate or view sensitive information.", + "ImpactStatement": "Only authorized individuals will be able to read and write chat messages during a meeting.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting engagement set Meeting chat to On for everyone but anonymous users. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to set the minimum recommended state: Set-CsTeamsMeetingPolicy -Identity Global -MeetingChatEnabledType \"EnabledExceptAnonymous\" Note: The audit section outlines additional compliant states which are more restrictive than the recommended state.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting engagement verify that Meeting chat is set to On for everyone but anonymous users or a more restrictive value: In-meeting only except anonymous or Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsMeetingPolicy -Identity Global | fl MeetingChatEnabledType 3. Verify that the returned value is EnabledExceptAnonymous or a more restrictive value EnabledInMeetingOnlyForAllExceptAnonymous or Disabled.", + "AdditionalInformation": "", + "DefaultValue": "On for everyone (Enabled)", + "References": "https://learn.microsoft.com/en-us/powershell/module/skype/set-csteamsmeetingpolicy?view=skype-ps#-meetingchatenabledtype" + } + ] + }, + { + "Id": "8.5.6", + "Description": "This policy setting controls who can present in a Teams meeting. Note: Organizers and co-organizers can change this setting when the meeting is set up.", + "Checks": [ + "teams_meeting_presenters_restricted" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.5 Meetings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "This policy setting controls who can present in a Teams meeting. Note: Organizers and co-organizers can change this setting when the meeting is set up.", + "RationaleStatement": "Ensuring that only authorized individuals are able to present reduces the risk that a malicious user can inadvertently show content that is not appropriate.", + "ImpactStatement": "Only organizers and co-organizers will be able to present without being granted permission.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under content sharing set Who can present to Only organizers and co- organizers. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to set the recommended state: Set-CsTeamsMeetingPolicy -Identity Global -DesignatedPresenterRoleMode \"OrganizerOnlyUserOverride\"", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under content sharing verify Who can present is set to Only organizers and co-organizers. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsMeetingPolicy -Identity Global | fl DesignatedPresenterRoleMode 3. Verify that the returned value is OrganizerOnlyUserOverride.", + "AdditionalInformation": "", + "DefaultValue": "Everyone (EveryoneUserOverride)", + "References": "https://learn.microsoft.com/en-US/microsoftteams/meeting-who-present-request-control:https://learn.microsoft.com/en-us/microsoftteams/meeting-who-present-request-control#manage-who-can-present:https://learn.microsoft.com/en-us/defender-office-365/step-by-step-guides/reducing-attack-surface-in-microsoft-teams?view=o365-worldwide#configure-meeting-settings-restrict-presenters:https://learn.microsoft.com/en-us/powershell/module/skype/set-csteamsmeetingpolicy?view=skype-ps" + } + ] + }, + { + "Id": "8.5.7", + "Description": "This policy setting allows control of who can present in meetings and who can request control of the presentation while a meeting is underway.", + "Checks": [ + "teams_meeting_external_control_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.5 Meetings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This policy setting allows control of who can present in meetings and who can request control of the presentation while a meeting is underway.", + "RationaleStatement": "Ensuring that only authorized individuals and not external participants are able to present and request control reduces the risk that a malicious user can inadvertently show content that is not appropriate. External participants are categorized as follows: external users, guests, and anonymous users.", + "ImpactStatement": "External participants will not be able to present or request control during the meeting. Warning: This setting also affects webinars. Note: At this time, to give and take control of shared content during a meeting, both parties must be using the Teams desktop client. Control isn't supported when either party is running Teams in a browser.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under content sharing set External participants can give or request control to Off. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to set the recommended state: Set-CsTeamsMeetingPolicy -Identity Global - AllowExternalParticipantGiveRequestControl $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under content sharing verify that External participants can give or request control is Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsMeetingPolicy -Identity Global | fl AllowExternalParticipantGiveRequestControl 3. Verify that the returned value is False.", + "AdditionalInformation": "", + "DefaultValue": "Off (False)", + "References": "https://learn.microsoft.com/en-us/microsoftteams/meeting-who-present-request-control:https://learn.microsoft.com/en-us/powershell/module/skype/set-csteamsmeetingpolicy?view=skype-ps" + } + ] + }, + { + "Id": "8.5.8", + "Description": "This meeting policy setting controls whether users can read or write messages in external meeting chats with untrusted organizations. If an external organization is on the list of trusted organizations this setting will be ignored.", + "Checks": [ + "teams_meeting_external_chat_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.5 Meetings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "This meeting policy setting controls whether users can read or write messages in external meeting chats with untrusted organizations. If an external organization is on the list of trusted organizations this setting will be ignored.", + "RationaleStatement": "Restricting access to chat in meetings hosted by external organizations limits the opportunity for an exploit like GIFShell or DarkGate malware from being delivered to users.", + "ImpactStatement": "When joining external meetings users will be unable to read or write chat messages in Teams meetings with organizations that they don't have a trust relationship with. This will completely remove the chat functionality in meetings. From an I.T. perspective both the upkeep of adding new organizations to the trusted list and the decision-making process behind whether to trust or not trust an external partner will increase time expenditure.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab.. 3. Select Meetings to open the meeting settings section. 4. Under meeting engagement set External meeting chat to Off. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to set the recommended state: Set-CsTeamsMeetingPolicy -Identity Global -AllowExternalNonTrustedMeetingChat $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting engagement verify that External meeting chat is set to Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsMeetingPolicy -Identity Global | fl AllowExternalNonTrustedMeetingChat 3. Verify that the returned value is False.", + "AdditionalInformation": "", + "DefaultValue": "On(True)", + "References": "https://learn.microsoft.com/en-us/microsoftteams/settings-policies-reference#meeting-engagement" + } + ] + }, + { + "Id": "8.5.9", + "Description": "This setting controls the ability for a user to initiate a recording of a meeting in progress. The recommended state is Off for the Global (Org-wide default) meeting policy.", + "Checks": [ + "teams_meeting_recording_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.5 Meetings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "This setting controls the ability for a user to initiate a recording of a meeting in progress. The recommended state is Off for the Global (Org-wide default) meeting policy.", + "RationaleStatement": "Disabling meeting recordings in the Global meeting policy ensures that only authorized users, such as organizers, co-organizers, and leads, can initiate a recording. This measure helps safeguard sensitive information by preventing unauthorized individuals from capturing and potentially sharing meeting content. Restricting recording capabilities to specific roles allows organizations to exercise greater control over what is recorded, aligning it with the meeting's confidentiality requirements. Note: Creating a separate policy for users or groups who are allowed to record is expected and in compliance. This control is only for the default meeting policy.", + "ImpactStatement": "If there are no additional policies allowing anyone to record, then recording will effectively be disabled.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under Recording & transcription set Meeting recording to Off. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to set the recommended state: Set-CsTeamsMeetingPolicy -Identity Global -AllowCloudRecording $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under Recording & transcription verify that Meeting recording is set to Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsMeetingPolicy -Identity Global | fl AllowCloudRecording 3. Verify that the returned value is False.", + "AdditionalInformation": "", + "DefaultValue": "On (True)", + "References": "https://learn.microsoft.com/en-us/microsoftteams/settings-policies-reference#recording--transcription" + } + ] + }, + { + "Id": "8.6.1", + "Description": "User reporting settings allow a user to report a message as malicious for further analysis. This recommendation is composed of 3 different settings and all must be configured for compliance: - In the Teams admin center: On by default and controls whether users are able to report messages from Teams. When this setting is turned off, users can't report messages within Teams, so the corresponding setting in the Microsoft 365 Defender portal is irrelevant. - In the Microsoft 365 Defender portal: On by default for new tenants. Existing tenants need to enable it. If user reporting of messages is turned on in the Teams admin center, it also needs to be turned on the Defender portal for user reported messages to show up correctly on the User reported tab on the Submissions page. - Defender - Report message destinations: This applies to more than just Microsoft Teams and allows for an organization to keep their reports contained. Due to how the parameters are configured on the backend it is included in this assessment as a requirement.", + "Checks": [ + "teams_security_reporting_enabled", + "defender_chat_report_policy_configured" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.6 Messaging", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "User reporting settings allow a user to report a message as malicious for further analysis. This recommendation is composed of 3 different settings and all must be configured for compliance: - In the Teams admin center: On by default and controls whether users are able to report messages from Teams. When this setting is turned off, users can't report messages within Teams, so the corresponding setting in the Microsoft 365 Defender portal is irrelevant. - In the Microsoft 365 Defender portal: On by default for new tenants. Existing tenants need to enable it. If user reporting of messages is turned on in the Teams admin center, it also needs to be turned on the Defender portal for user reported messages to show up correctly on the User reported tab on the Submissions page. - Defender - Report message destinations: This applies to more than just Microsoft Teams and allows for an organization to keep their reports contained. Due to how the parameters are configured on the backend it is included in this assessment as a requirement.", + "RationaleStatement": "Users will be able to more quickly and systematically alert administrators of suspicious malicious messages within Teams. The content of these messages may be sensitive in nature and therefore should be kept within the organization and not shared with Microsoft without first consulting company policy. Note: - The reported message remains visible to the user in the Teams client. - Users can report the same message multiple times. - The message sender isn't notified that messages were reported.", + "ImpactStatement": "Enabling message reporting has an impact beyond just addressing security concerns. When users of the platform report a message, the content could include messages that are threatening or harassing in nature, possibly stemming from colleagues. Due to this the security staff responsible for reviewing and acting on these reports should be equipped with the skills to discern and appropriately direct such messages to the relevant departments, such as Human Resources (HR).", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Messaging to open the messaging settings section. 4. Set Report a security concern to On. 5. Next, navigate to Microsoft 365 Defender https://security.microsoft.com/ 6. Expand System > Settings and select Email & collaboration. 7. Click on User reported settings. 8. Under Microsoft Teams check the box for Monitor reported items in Microsoft Teams and click Save. 9. Set Send reported messages to: to My reporting mailbox only with reports configured to be sent to authorized staff. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Connect to Exchange Online PowerShell using Connect-ExchangeOnline. 3. Run the following cmdlet: Set-CsTeamsMessagingPolicy -Identity Global -AllowSecurityEndUserReporting $true 4. To configure the Defender reporting policies, edit and run this script: $usersub = \"userreportedmessages@fabrikam.com\" # Change this. $params = @{ Identity = \"DefaultReportSubmissionPolicy\" EnableReportToMicrosoft = $false ReportChatMessageEnabled = $false ReportChatMessageToCustomizedAddressEnabled = $true ReportJunkToCustomizedAddress = $true ReportNotJunkToCustomizedAddress = $true ReportPhishToCustomizedAddress = $true ReportJunkAddresses = $usersub ReportNotJunkAddresses = $usersub ReportPhishAddresses = $usersub } Set-ReportSubmissionPolicy @params New-ReportSubmissionRule -Name DefaultReportSubmissionRule - ReportSubmissionPolicy DefaultReportSubmissionPolicy -SentTo $usersub", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Messaging to open the messaging settings section. 4. Verify that Report a security concern is On. 5. Next, navigate to Microsoft 365 Defender https://security.microsoft.com/ 6. Expand System > Settings and select Email & collaboration. 7. Click on User reported settings. 8. Under Microsoft Teams verify that Monitor reported items in Microsoft Teams is checked. 9. Verify that Send reported messages to: is set to My reporting mailbox only with report email addresses defined for authorized staff. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following cmdlet for to assess Teams: Get-CsTeamsMessagingPolicy -Identity Global | fl AllowSecurityEndUserReporting 3. Verify that the value returned is True. 4. Connect to Exchange Online PowerShell using Connect-ExchangeOnline. 5. Run this cmdlet to assess Defender: Get-ReportSubmissionPolicy | fl Report* 6. Verify that the output matches the following values with organization specific email addresses: ReportJunkToCustomizedAddress : True ReportNotJunkToCustomizedAddress : True ReportPhishToCustomizedAddress : True ReportJunkAddresses : {SOC@contoso.com} ReportNotJunkAddresses : {SOC@contoso.com} ReportPhishAddresses : {SOC@contoso.com} ReportChatMessageEnabled : False ReportChatMessageToCustomizedAddressEnabled : True", + "AdditionalInformation": "", + "DefaultValue": "On (True) Report message destination: Microsoft Only", + "References": "https://learn.microsoft.com/en-us/defender-office-365/submissions-teams?view=o365-worldwide" + } + ] + }, + { + "Id": "9.1.1", + "Description": "This setting allows business-to-business (B2B) guests access to Microsoft Fabric, and contents that they have permissions to. With the setting turned off, B2B guest users receive an error when trying to access Power BI. The recommended state is Enabled for a subset of the organization or Disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting allows business-to-business (B2B) guests access to Microsoft Fabric, and contents that they have permissions to. With the setting turned off, B2B guest users receive an error when trying to access Power BI. The recommended state is Enabled for a subset of the organization or Disabled.", + "RationaleStatement": "Establishing and enforcing a dedicated security group prevents unauthorized access to Microsoft Fabric for guests collaborating in Azure that are new or assigned guest status from other applications. This upholds the principle of least privilege and uses role-based access control (RBAC). These security groups can also be used for tasks like conditional access, enhancing risk management and user accountability across the organization.", + "ImpactStatement": "Security groups will need to be more closely tended to and monitored.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Set Guest users can access Microsoft Fabric to one of these states: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Verify that Guest users can access Microsoft Fabric is set to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName AllowGuestUserToAccessSharedContent in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is false. o enabled is true AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Enabled for the entire organization", + "References": "https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-export-sharing" + } + ] + }, + { + "Id": "9.1.2", + "Description": "This setting helps organizations choose whether new external users can be invited to the organization through Power BI sharing, permissions, and subscription experiences. This setting only controls the ability to invite through Power BI. The recommended state is Enabled for a subset of the organization or Disabled. Note: To invite external users to the organization, the user must also have the Microsoft Entra Guest Inviter role.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting helps organizations choose whether new external users can be invited to the organization through Power BI sharing, permissions, and subscription experiences. This setting only controls the ability to invite through Power BI. The recommended state is Enabled for a subset of the organization or Disabled. Note: To invite external users to the organization, the user must also have the Microsoft Entra Guest Inviter role.", + "RationaleStatement": "Establishing and enforcing a dedicated security group prevents unauthorized access to Microsoft Fabric for guests collaborating in Azure that are new or assigned guest status from other applications. This upholds the principle of least privilege and uses role-based access control (RBAC). These security groups can also be used for tasks like conditional access, enhancing risk management and user accountability across the organization.", + "ImpactStatement": "Guest user invitations will be limited to only specific employees.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Set Users can invite guest users to collaborate through item sharing and permissions to one of these states: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Verify that Users can invite guest users to collaborate through item sharing and permissions is set to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName ExternalSharingV2 in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is false. o enabled is true AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Enabled for the entire organization", + "References": "https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-export-sharing:https://learn.microsoft.com/en-us/power-bi/enterprise/service-admin-azure-ad-b2b#invite-guest-users" + } + ] + }, + { + "Id": "9.1.3", + "Description": "This setting allows Microsoft Entra B2B guest users to have full access to the browsing experience using the left-hand navigation pane in the organization. Guest users who have been assigned workspace roles or specific item permissions will continue to have those roles and/or permissions, even if this setting is disabled. The recommended state is Enabled for a subset of the organization or Disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting allows Microsoft Entra B2B guest users to have full access to the browsing experience using the left-hand navigation pane in the organization. Guest users who have been assigned workspace roles or specific item permissions will continue to have those roles and/or permissions, even if this setting is disabled. The recommended state is Enabled for a subset of the organization or Disabled.", + "RationaleStatement": "Establishing and enforcing a dedicated security group prevents unauthorized access to Microsoft Fabric for guests collaborating in Entra that are new or assigned guest status from other applications. This upholds the principle of least privilege and uses role-based access control (RBAC). These security groups can also be used for tasks like conditional access, enhancing risk management and user accountability across the organization.", + "ImpactStatement": "Security groups will need to be more closely tended to and monitored.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Set Guest users can browse and access Fabric content to one of these states: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Verify that Guest users can browse and access Fabric content is set to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName ElevatedGuestsTenant in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is false. o enabled is true AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Disabled", + "References": "https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-export-sharing" + } + ] + }, + { + "Id": "9.1.4", + "Description": "Power BI enables users to share reports and materials directly on the internet from both the application's desktop version and its web user interface. This functionality generates a publicly reachable web link that doesn't necessitate authentication or the need to be an Entra ID user in order to access and view it. The recommended state is Enabled for a subset of the organization or Disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Power BI enables users to share reports and materials directly on the internet from both the application's desktop version and its web user interface. This functionality generates a publicly reachable web link that doesn't necessitate authentication or the need to be an Entra ID user in order to access and view it. The recommended state is Enabled for a subset of the organization or Disabled.", + "RationaleStatement": "When using Publish to Web anyone on the Internet can view a published report or visual. Viewing requires no authentication. It includes viewing detail-level data that your reports aggregate. By disabling the feature, restricting access to certain users and allowing existing embed codes organizations can mitigate the exposure of confidential or proprietary information.", + "ImpactStatement": "Depending on the organization's utilization administrators may experience more overhead managing embed codes, and requests.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Set Publish to web to one of these states: o Disabled o Enabled with Choose how embed codes work set to Only allow existing codes AND Specific security groups selected and defined Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Verify that Publish to web is set to one of the following: o Disabled o Enabled with Choose how embed codes work set to Only allow existing codes AND Specific security groups selected and defined Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName PublishToWeb in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is false. o enabled is true AND createP2w is false AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: The createP2w property can be found nested under properties.", + "AdditionalInformation": "", + "DefaultValue": "Enabled for the entire organization Only allow existing codes", + "References": "https://learn.microsoft.com/en-us/power-bi/collaborate-share/service-publish-to-web:https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-export-sharing#publish-to-web" + } + ] + }, + { + "Id": "9.1.5", + "Description": "Power BI allows the integration of R and Python scripts directly into visuals. This feature allows data visualizations by incorporating custom calculations, statistical analyses, machine learning models, and more using R or Python scripts. Custom visuals can be created by embedding them directly into Power BI reports. Users can then interact with these visuals and see the results of the custom code within the Power BI interface.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Power BI allows the integration of R and Python scripts directly into visuals. This feature allows data visualizations by incorporating custom calculations, statistical analyses, machine learning models, and more using R or Python scripts. Custom visuals can be created by embedding them directly into Power BI reports. Users can then interact with these visuals and see the results of the custom code within the Power BI interface.", + "RationaleStatement": "Disabling this feature can reduce the attack surface by preventing potential malicious code execution leading to data breaches, or unauthorized access. The potential for sensitive or confidential data being leaked to unintended users is also increased with the use of scripts.", + "ImpactStatement": "Use of R and Python scripting will require exceptions for developers, along with more stringent code review.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to R and Python visuals settings. 4. Set Interact with and share R and Python visuals to Disabled", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to R and Python visuals settings. 4. Verify that Interact with and share R and Python visuals is set to Disabled To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName RScriptVisual in the output. 3. Verify that enabled is false.", + "AdditionalInformation": "", + "DefaultValue": "Enabled", + "References": "https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-r-python-visuals:https://learn.microsoft.com/en-us/power-bi/visuals/service-r-visuals:https://www.r-project.org/" + } + ] + }, + { + "Id": "9.1.6", + "Description": "Information protection tenant settings help to protect sensitive information in the Power BI tenant. Allowing and applying sensitivity labels to content ensures that information is only seen and accessed by the appropriate users. The recommended state is Enabled or Enabled for a subset of the organization. Note: Sensitivity labels and protection are only applied to files exported to Excel, PowerPoint, or PDF files, that are controlled by \"Export to Excel\" and \"Export reports as PowerPoint presentation or PDF documents\" settings. All other export and sharing options do not support the application of sensitivity labels and protection. Note 2: There are some prerequisite steps that need to be completed in order to fully utilize labeling. See here.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Information protection tenant settings help to protect sensitive information in the Power BI tenant. Allowing and applying sensitivity labels to content ensures that information is only seen and accessed by the appropriate users. The recommended state is Enabled or Enabled for a subset of the organization. Note: Sensitivity labels and protection are only applied to files exported to Excel, PowerPoint, or PDF files, that are controlled by \"Export to Excel\" and \"Export reports as PowerPoint presentation or PDF documents\" settings. All other export and sharing options do not support the application of sensitivity labels and protection. Note 2: There are some prerequisite steps that need to be completed in order to fully utilize labeling. See here.", + "RationaleStatement": "Establishing data classifications and affixing labels to data at creation enables organizations to discern the data's criticality, sensitivity, and value. This initial identification enables the implementation of appropriate protective measures, utilizing technologies like Data Loss Prevention (DLP) to avert inadvertent exposure and enforcing access controls to safeguard against unauthorized access. This practice can also promote user awareness and responsibility in regard to the nature of the data they interact with. Which in turn can foster awareness in other areas of data management across the organization.", + "ImpactStatement": "Additional license requirements like Power BI Pro are required, as outlined in the Licensed and requirements page linked in the description and references sections.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Information protection. 4. Set Allow users to apply sensitivity labels for content to one of these states: o Enabled o Enabled with Specific security groups selected and defined.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Information protection. 4. Verify that Allow users to apply sensitivity labels for content is set to one of the following: o Enabled o Enabled with Specific security groups selected and defined. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName EimInformationProtectionEdit in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is true. o enabled is true AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Disabled", + "References": "https://learn.microsoft.com/en-us/power-bi/enterprise/service-security-enable-data-sensitivity-labels:https://learn.microsoft.com/en-us/fabric/governance/data-loss-prevention-overview:https://learn.microsoft.com/en-us/power-bi/enterprise/service-security-enable-data-sensitivity-labels#licensing-and-requirements" + } + ] + }, + { + "Id": "9.1.7", + "Description": "Creating a shareable link allows a user to create a link to a report or dashboard, then add that link to an email or another messaging application. There are 3 options that can be selected when creating a shareable link: - People in your organization - People with existing access - Specific people This setting solely deals with restrictions to People in the organization. External users by default are not included in any of these categories, and therefore cannot use any of these links regardless of the state of this setting. The recommended state is Enabled for a subset of the organization or Disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Creating a shareable link allows a user to create a link to a report or dashboard, then add that link to an email or another messaging application. There are 3 options that can be selected when creating a shareable link: - People in your organization - People with existing access - Specific people This setting solely deals with restrictions to People in the organization. External users by default are not included in any of these categories, and therefore cannot use any of these links regardless of the state of this setting. The recommended state is Enabled for a subset of the organization or Disabled.", + "RationaleStatement": "While external users are unable to utilize shareable links, disabling or restricting this feature ensures that a user cannot generate a link accessible by individuals within the same organization who lack the necessary clearance to the shared data. For example, a member of Human Resources intends to share sensitive information with a particular employee or another colleague within their department. The owner would be prompted to specify either People with existing access or Specific people when generating the link requiring the person clicking the link to pass a first layer access control list. This measure along with proper file and folder permissions can help prevent unintended access and potential information leakage.", + "ImpactStatement": "If the setting is Enabled then only specific people in the organization would be allowed to create general links viewable by the entire organization.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Set Allow shareable links to grant access to everyone in your organization to one of these states: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Verify that Allow shareable links to grant access to everyone in your organization is set to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName ShareLinkToEntireOrg in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is set to false. o enabled is set to true AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Enabled for the entire organization", + "References": "https://learn.microsoft.com/en-us/power-bi/collaborate-share/service-share-dashboards?wt.mc_id=powerbi_inproduct_sharedialog#link-settings:https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-export-sharing" + } + ] + }, + { + "Id": "9.1.8", + "Description": "Power BI admins can specify which users or user groups can share datasets externally with guests from a different tenant through the in-place mechanism. Disabling this setting prevents any user from sharing datasets externally by restricting the ability of users to turn on external sharing for datasets they own or manage. The recommended state is Enabled for a subset of the organization or Disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Power BI admins can specify which users or user groups can share datasets externally with guests from a different tenant through the in-place mechanism. Disabling this setting prevents any user from sharing datasets externally by restricting the ability of users to turn on external sharing for datasets they own or manage. The recommended state is Enabled for a subset of the organization or Disabled.", + "RationaleStatement": "Establishing and enforcing a dedicated security group prevents unauthorized access to Microsoft Fabric for guests collaborating in Azure that are new or from other applications. This upholds the principle of least privilege and uses role-based access control (RBAC). These security groups can also be used for tasks like conditional access, enhancing risk management and user accountability across the organization.", + "ImpactStatement": "Security groups will need to be more closely tended to and monitored.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Set Allow specific users to turn on external data sharing to one of these states: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Verify that Allow specific users to turn on external data sharing is set to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName EnableDatasetInPlaceSharing in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is set to false. o enabled is set to true AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Enabled for the entire organization", + "References": "https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-export-sharing" + } + ] + }, + { + "Id": "9.1.9", + "Description": "This setting blocks the use of resource key based authentication. The Block ResourceKey Authentication setting applies to streaming and PUSH datasets. If blocked users will not be allowed to send data to streaming and PUSH datasets using the API with a resource key. The recommended state is Enabled.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting blocks the use of resource key based authentication. The Block ResourceKey Authentication setting applies to streaming and PUSH datasets. If blocked users will not be allowed to send data to streaming and PUSH datasets using the API with a resource key. The recommended state is Enabled.", + "RationaleStatement": "Resource keys are a form of authentication that allows users to access Power BI resources (such as reports, dashboards, and datasets) without requiring individual user accounts. While convenient, this method bypasses the organization's centralized identity and access management controls. Enabling ensures that access to Power BI resources is tied to the organization's authentication mechanisms, providing a more secure and controlled environment.", + "ImpactStatement": "Developers will need to request a special exception in order to use this feature.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Developer settings. 4. Set Block ResourceKey Authentication to Enabled", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Developer settings. 4. Verify that Block ResourceKey Authentication is set to Enabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName BlockResourceKeyAuthentication in the output. 3. Verify that enabled is set to true. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Disabled for the entire organization", + "References": "https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-developer:https://learn.microsoft.com/en-us/power-bi/connect-data/service-real-time-streaming" + } + ] + }, + { + "Id": "9.1.10", + "Description": "Use a service principal to access Fabric public APIs that include create, read, update, and delete (CRUD) operations, and are protected by a Fabric permission model. To allow an app to use service principal authentication, its service principal must be included in an allowed security group. You can control who can access service principals by creating dedicated security groups and using these groups in other tenant settings. The recommended state is Enabled for a subset of the organization or Disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Use a service principal to access Fabric public APIs that include create, read, update, and delete (CRUD) operations, and are protected by a Fabric permission model. To allow an app to use service principal authentication, its service principal must be included in an allowed security group. You can control who can access service principals by creating dedicated security groups and using these groups in other tenant settings. The recommended state is Enabled for a subset of the organization or Disabled.", + "RationaleStatement": "Leaving API access unrestricted increases the attack surface in the event an adversary gains access to a Service Principal. APIs are a feature-rich method for programmatic access to many areas of Power Bi and should be guarded closely.", + "ImpactStatement": "Service principals will need to be members of specific security groups in order to perform public API calls.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Developer settings. 4. Set Service principals can call Fabric public APIs to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Developer settings. 4. Verify that Service principals can call Fabric public APIs is set to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName ServicePrincipalAccessPermissionAPIs in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is set to false. o enabled is set to true AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Enabled for the entire organization", + "References": "https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-developer" + } + ] + }, + { + "Id": "9.1.11", + "Description": "Service principal profiles provide a flexible solution for apps used in a multitenancy deployment. The profiles enable customer data isolation and tighter security boundaries between customers that are utilizing the app. The recommended state is Enabled for a subset of the organization or Disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Service principal profiles provide a flexible solution for apps used in a multitenancy deployment. The profiles enable customer data isolation and tighter security boundaries between customers that are utilizing the app. The recommended state is Enabled for a subset of the organization or Disabled.", + "RationaleStatement": "Service Principals should be restricted to a security group to limit which Service Principals can interact with profiles. This supports the principle of least privilege.", + "ImpactStatement": "Disabled is the default behavior.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Developer settings. 4. Set Allow service principals to create and use profiles to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Developer settings. 4. Verify that Allow service principals to create and use profiles is set to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName AllowServicePrincipalsCreateAndUseProfiles in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is set to false. o enabled is set to true AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Disabled for the entire organization", + "References": "https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-developer:https://learn.microsoft.com/en-us/power-bi/developer/embedded/embed-multi-tenancy" + } + ] + }, + { + "Id": "9.1.12", + "Description": "Use a service principal to access these Fabric APIs that aren't protected by a Fabric permission model. - Create Workspace - Create Connection - Create Deployment Pipeline To allow an app to use service principal authentication, its service principal must be included in an allowed security group. You can control who can access service principals by creating dedicated security groups and using these groups in other tenant settings. The recommended state is Enabled for a subset of the organization or Disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Use a service principal to access these Fabric APIs that aren't protected by a Fabric permission model. - Create Workspace - Create Connection - Create Deployment Pipeline To allow an app to use service principal authentication, its service principal must be included in an allowed security group. You can control who can access service principals by creating dedicated security groups and using these groups in other tenant settings. The recommended state is Enabled for a subset of the organization or Disabled.", + "RationaleStatement": "Leaving API access unrestricted increases the attack surface in the event an adversary gains access to a Service Principal. APIs are a feature-rich method for programmatic access to many areas of Power Bi and should be guarded closely.", + "ImpactStatement": "Service principals will need to be members of specific security groups in order to perform public API calls.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Developer settings. 4. Set Service principals can create workspaces, connections, and deployment pipelines to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Developer settings. 4. Verify that Service principals can create workspaces, connections, and deployment pipelines is set to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName ServicePrincipalAccessGlobalAPIs in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is set to false. o enabled is set to true AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Disabled for the entire organization", + "References": "https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-developer" + } + ] + } + ] +} \ No newline at end of file diff --git a/prowler/compliance/m365/prowler_threatscore_m365.json b/prowler/compliance/m365/prowler_threatscore_m365.json index 286a4fff39..f5a6cd1cd8 100644 --- a/prowler/compliance/m365/prowler_threatscore_m365.json +++ b/prowler/compliance/m365/prowler_threatscore_m365.json @@ -819,6 +819,14 @@ "LevelOfRisk": 4, "Weight": 100 } + ], + "ConfigRequirements": [ + { + "Check": "entra_admin_users_sign_in_frequency_enabled", + "ConfigKey": "sign_in_frequency", + "Operator": "lte", + "Value": 4 + } ] }, { @@ -964,6 +972,68 @@ "LevelOfRisk": 2, "Weight": 8 } + ], + "ConfigRequirements": [ + { + "Check": "defender_malware_policy_comprehensive_attachments_filter_applied", + "ConfigKey": "recommended_blocked_file_types", + "Operator": "superset", + "Value": [ + "ace", + "ani", + "apk", + "app", + "appx", + "arj", + "bat", + "cab", + "cmd", + "com", + "deb", + "dex", + "dll", + "docm", + "elf", + "exe", + "hta", + "img", + "iso", + "jar", + "jnlp", + "kext", + "lha", + "lib", + "library", + "lnk", + "lzh", + "macho", + "msc", + "msi", + "msix", + "msp", + "mst", + "pif", + "ppa", + "ppam", + "reg", + "rev", + "scf", + "scr", + "sct", + "sys", + "uif", + "vb", + "vbe", + "vbs", + "vxd", + "wsc", + "wsf", + "wsh", + "xll", + "xz", + "z" + ] + } ] }, { diff --git a/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json b/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json index bac3d9132e..66747cdba7 100644 --- a/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json +++ b/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json @@ -25,6 +25,14 @@ "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." } + ], + "ConfigRequirements": [ + { + "Check": "signon_global_session_idle_timeout_15min", + "ConfigKey": "okta_max_session_idle_minutes", + "Operator": "lte", + "Value": 15 + } ] }, { @@ -46,6 +54,14 @@ "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." } + ], + "ConfigRequirements": [ + { + "Check": "application_admin_console_session_idle_timeout_15min", + "ConfigKey": "okta_admin_console_idle_timeout_max_minutes", + "Operator": "lte", + "Value": 15 + } ] }, { @@ -69,6 +85,14 @@ "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\"." } + ], + "ConfigRequirements": [ + { + "Check": "user_inactivity_automation_35d_enabled", + "ConfigKey": "okta_user_inactivity_max_days", + "Operator": "lte", + "Value": 35 + } ] }, { @@ -395,6 +419,14 @@ "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." } + ], + "ConfigRequirements": [ + { + "Check": "signon_global_session_lifetime_18h", + "ConfigKey": "okta_max_session_lifetime_minutes", + "Operator": "lte", + "Value": 1080 + } ] }, { diff --git a/prowler/config/config.py b/prowler/config/config.py index 78b48756fe..4d0b631606 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -49,7 +49,7 @@ class _MutableTimestamp: timestamp = _MutableTimestamp(datetime.today()) timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc)) -prowler_version = "5.32.0" +prowler_version = "5.33.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" @@ -88,15 +88,22 @@ 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}.""" + """Discover compliance directories from entry points. Returns {provider: [paths]}. + + A provider may be contributed by several packages, so accumulate every + directory instead of overwriting. + """ 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] + path = module.__path__[0] elif hasattr(module, "__file__"): - dirs[ep.name] = os.path.dirname(module.__file__) + path = os.path.dirname(module.__file__) + else: + continue + dirs.setdefault(ep.name, []).append(path) except Exception as error: logger.warning( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -145,12 +152,15 @@ 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. + # External compliance via entry points; a provider may be served by + # several packages, so iterate every directory it contributes. ep_dirs = _get_ep_compliance_dirs() - for prov, path in ep_dirs.items(): + for prov, paths in ep_dirs.items(): if provider and prov != provider: continue - if os.path.isdir(path): + for path in paths: + 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") diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index 9d41a0e592..3a059e11d0 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -3,6 +3,32 @@ aws: # AWS Global Configuration # aws.mute_non_default_regions --> Set to True to muted failed findings in non-default regions for AccessAnalyzer, GuardDuty, SecurityHub, DRS and Config mute_non_default_regions: False + + # AWS Resource Scan Limit Configuration + # Limits the number of resources scanned per service for services that can + # accumulate huge numbers of resources (EBS snapshots, backup recovery + # points, CloudWatch log groups, Lambda functions, ECS task definitions, + # CodeArtifact packages). Limits apply to resources analyzed, not findings: + # a selected resource can produce zero, one, or many findings. Where the AWS + # API supports server-side ordering the latest resources are scanned first; + # otherwise it is best-effort API order. + # Disabled by default: scan every resource unless a positive limit is configured. + # Set to 0 (or a negative value) to disable the limit (scan every resource). + # aws.max_scanned_resources_per_service --> global default for all services below + max_scanned_resources_per_service: 0 + # Per-service overrides. Leave as null to fall back to the global default. + # aws.max_ebs_snapshots --> ec2_ebs_* checks (EBS snapshots) + max_ebs_snapshots: null + # aws.max_backup_recovery_points --> backup_recovery_point_* checks + max_backup_recovery_points: null + # aws.max_cloudwatch_log_groups --> cloudwatch_log_group_* checks + max_cloudwatch_log_groups: null + # aws.max_lambda_functions --> awslambda_function_* checks + max_lambda_functions: null + # aws.max_ecs_task_definitions --> ecs_task_definitions_* checks + max_ecs_task_definitions: null + # aws.max_codeartifact_packages --> codeartifact_packages_* checks + max_codeartifact_packages: null # aws.disallowed_regions --> List of AWS regions to exclude from the scan. # Also settable via the PROWLER_AWS_DISALLOWED_REGIONS environment variable or # the --excluded-region CLI flag. Precedence: CLI > env var > config file. @@ -423,6 +449,27 @@ aws: # Patterns to ignore in the secrets checks secrets_ignore_patterns: [] + # aws.awslambda_function_no_secrets_in_code + # Glob patterns of file names inside the Lambda deployment package to skip + # when scanning for secrets. Useful to suppress known false positives such + # as .NET dependency manifests. + # Example: + # secrets_ignore_files: + # - "*.deps.json" + # WARNING: use at your own risk. Any file whose name matches one of these + # patterns is fully excluded from secret scanning, so a real secret placed + # in such a file will NOT be detected. Keep patterns as narrow and specific + # as possible; this is not recommended unless you have confirmed the matched + # files only ever contain false positives. + secrets_ignore_files: [] + + # Validate discovered secrets by checking whether they are live against the + # provider APIs. WARNING: this makes outbound network calls that authenticate + # with the discovered secret itself; the credential is exercised against the + # provider and the call will appear in the audited account's logs (and may + # trigger its monitoring). Disabled by default (scans stay fully offline). + secrets_validate: False + # AWS Secrets Manager Configuration # aws.secretsmanager_secret_unused # Maximum number of days a secret can be unused @@ -436,36 +483,17 @@ aws: # Minimum retention period in hours for Kinesis streams min_kinesis_stream_retention_hours: 168 # 7 days - # Detect Secrets plugin configuration - detect_secrets_plugins: [ - {"name": "ArtifactoryDetector"}, - {"name": "AWSKeyDetector"}, - {"name": "AzureStorageKeyDetector"}, - {"name": "BasicAuthDetector"}, - {"name": "CloudantDetector"}, - {"name": "DiscordBotTokenDetector"}, - {"name": "GitHubTokenDetector"}, - {"name": "GitLabTokenDetector"}, - {"name": "Base64HighEntropyString", "limit": 6.0}, - {"name": "HexHighEntropyString", "limit": 3.0}, - {"name": "IbmCloudIamDetector"}, - {"name": "IbmCosHmacDetector"}, - # {"name": "IPPublicDetector"}, https://github.com/Yelp/detect-secrets/pull/885 - {"name": "JwtTokenDetector"}, - {"name": "KeywordDetector"}, - {"name": "MailchimpDetector"}, - {"name": "NpmDetector"}, - {"name": "OpenAIDetector"}, - {"name": "PrivateKeyDetector"}, - {"name": "PypiTokenDetector"}, - {"name": "SendGridDetector"}, - {"name": "SlackDetector"}, - {"name": "SoftlayerDetector"}, - {"name": "SquareOAuthDetector"}, - {"name": "StripeDetector"}, - # {"name": "TelegramBotTokenDetector"}, https://github.com/Yelp/detect-secrets/pull/878 - {"name": "TwilioKeyDetector"}, - ] + # AWS S3 Configuration + # aws.s3_bucket_object_public + # This check performs a spot-check by sampling object ACLs within a bucket, so + # it is disabled by default. For complete coverage, rely on s3_bucket_acl_prohibited + # which enforces BucketOwnerEnforced Object Ownership (AWS's recommended approach). + # Set s3_bucket_object_public_enabled to True to opt in. + s3_bucket_object_public_enabled: False + # Maximum number of objects to list per bucket (upper bound for the sampling pool) + s3_bucket_object_public_max_objects: 100 + # Number of objects to randomly sample from the listed pool and inspect ACLs for + s3_bucket_object_public_sample_size: 3 # AWS CodeBuild Configuration # aws.codebuild_project_uses_allowed_github_organizations @@ -705,9 +733,71 @@ okta: # 15 per DISA STIG V-273186 (OKTA-APP-000020); raise it only with an # explicit risk acceptance. okta_max_session_idle_minutes: 15 + # okta.signon_global_session_lifetime_18h + # Maximum acceptable Global Session lifetime, in minutes. Defaults to + # 18h (1080); raise it only with an explicit risk acceptance. + okta_max_session_lifetime_minutes: 1080 # Okta Applications # okta.application_admin_console_session_idle_timeout_15min # Maximum acceptable Okta Admin Console app idle timeout, in minutes. # Defaults to 15 per DISA STIG V-273187 (OKTA-APP-000025); raise it only # with an explicit risk acceptance. okta_admin_console_idle_timeout_max_minutes: 15 + # Okta API rate limiting + # Max retries on HTTP 429. The Okta SDK sleeps until the X-Rate-Limit-Reset + # window before each retry, so raising this lets scans ride out more rate-limit + # windows on busy orgs instead of failing with partial data. SDK default is 2. + okta_max_retries: 5 + # Per-request timeout in seconds. In the Okta SDK this value plays a DUAL role: + # it is both the per-HTTP-call socket timeout AND the total wall-clock budget + # across the whole retry+backoff loop. It defaults to 300 (not 0) because it is + # the only effective hang guard, and 300s is the smallest value that still lets + # all okta_max_retries rate-limit waits (~60s Okta reset windows) complete + # without being cut short. Keep it roughly >= okta_max_retries * 60 if you + # raise okta_max_retries. + okta_request_timeout: 300 + # Maximum aggregate Okta API requests per second. Prowler paces all requests + # through a shared limiter so scans stay under Okta's rate limits proactively, + # rather than relying on the 429 retry above as a safety net. Okta enforces + # limits per endpoint, so this is a deliberately simple global cap; lower it if + # scans still hit limits, raise it to scan faster. Set to 0 to disable. Valid + # range: 0 or 0.1..100 — non-zero rates below 0.1 are rejected because they + # would make a scan impractically slow. + okta_requests_per_second: 4 + # Okta Users + # okta.user_inactivity_automation_35d_enabled + # Maximum number of days a user can stay inactive before the + # inactivity-automation check flags the org. Defaults to 35. + okta_user_inactivity_max_days: 35 + # Okta Identity Providers + # okta.idp_smart_card_dod_approved_ca + # Extra regex patterns matched against a Smart Card IdP certificate issuer + # DN to recognise a DOD-approved CA, on top of the built-in `OU=DoD` / + # `OU=ECA` patterns. + okta_dod_approved_ca_issuer_patterns: [] + +alibabacloud: + # alibabacloud.cs_kubernetes_cluster_check_recent / cs_kubernetes_cluster_check_weekly + # Maximum number of days an ACK cluster can go without a security check + # before being flagged. Defaults to 7. + max_cluster_check_days: 7 + # alibabacloud.ram_user_console_access_unused + # Days a RAM user's console access can stay unused before being flagged. + # Defaults to 90. + max_console_access_days: 90 + # alibabacloud.sls_logstore_retention_period + # Minimum required SLS log store retention, in days. Defaults to 365. + min_log_retention_days: 365 + # alibabacloud.rds_instance_sql_audit_retention + # Minimum required RDS SQL audit log retention, in days. Defaults to 180. + min_rds_audit_retention_days: 180 + +openstack: + # openstack.image_not_shared_with_multiple_projects + # Maximum number of accepted project members a shared image may have before + # being flagged. Defaults to 5. + image_sharing_threshold: 5 + # openstack._*_metadata_sensitive_data + # Regex patterns whose matches are excluded from secret scanning of + # resource metadata. + secrets_ignore_patterns: [] diff --git a/prowler/config/schema/alibabacloud.py b/prowler/config/schema/alibabacloud.py new file mode 100644 index 0000000000..104f8a3556 --- /dev/null +++ b/prowler/config/schema/alibabacloud.py @@ -0,0 +1,54 @@ +"""Alibaba Cloud provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class AlibabaCloudProviderConfig(ProviderConfigBase): + """Alibaba Cloud provider configuration schema. + + Bounds the retention and staleness thresholds consumed by the Alibaba + Cloud checks. Every field is optional: when omitted (or dropped for being + out of range) the check falls back to its own default via + ``audit_config.get(key, default)``. + """ + + max_cluster_check_days: Optional[int] = Field( + default=None, + ge=1, + le=365, + description=( + "Maximum number of days an ACK cluster can go without a security " + "check before being flagged. Range: 1..365 (defaults to 7)." + ), + ) + max_console_access_days: Optional[int] = Field( + default=None, + ge=30, + le=180, + description=( + "Days a RAM user's console access can stay unused before being " + "flagged. Range: 30..180 (defaults to 90)." + ), + ) + min_log_retention_days: Optional[int] = Field( + default=None, + ge=1, + le=3650, + description=( + "Minimum required SLS log store retention, in days. Range: " + "1..3650 (defaults to 365)." + ), + ) + min_rds_audit_retention_days: Optional[int] = Field( + default=None, + ge=1, + le=3650, + description=( + "Minimum required RDS SQL audit log retention, in days. Range: " + "1..3650 (defaults to 180)." + ), + ) diff --git a/prowler/config/schema/aws.py b/prowler/config/schema/aws.py index 1a44bb9bcc..4e52093029 100644 --- a/prowler/config/schema/aws.py +++ b/prowler/config/schema/aws.py @@ -14,7 +14,7 @@ thresholds) and avoids ints that obviously break downstream maths from typing import Annotated, Literal, Optional -from pydantic import AfterValidator, Field +from pydantic import AfterValidator, BeforeValidator, Field from prowler.config.schema.base import ProviderConfigBase from prowler.config.schema.validators import ( @@ -101,33 +101,65 @@ def _validate_account_ids(v: Optional[list[str]]) -> Optional[list[str]]: return v -# ---- Nested models ---------------------------------------------------------- +def _reject_bool_resource_limit(v): + if isinstance(v, bool): + raise ValueError("resource scan limits must be integers, not booleans") + return v -class _DetectSecretsPlugin(ProviderConfigBase): - """One entry inside ``detect_secrets_plugins``. - - Only ``name`` is required by the upstream library. ``limit`` is used by - the entropy detectors. Any other plugin-specific kwarg is preserved by - the ``extra="allow"`` policy inherited from ProviderConfigBase. - """ - - name: str - limit: Optional[float] = Field( - default=None, - ge=0.0, - le=10.0, - description=( - "Entropy threshold for detect-secrets entropy plugins. Range: 0..10 " - "(Shannon entropy is bounded by log2(256)=8; >10 is meaningless)." - ), - ) +ResourceScanLimit = Annotated[ + Optional[int], BeforeValidator(_reject_bool_resource_limit) +] # ---- Main schema ------------------------------------------------------------ class AWSProviderConfig(ProviderConfigBase): + # --- Resource scan limits --------------------------------------------- + max_scanned_resources_per_service: ResourceScanLimit = Field( + default=None, + ge=-1, + le=1_000_000, + description="Global resource scan limit for high-volume AWS services. Use 0 or -1 to disable.", + ) + max_ebs_snapshots: ResourceScanLimit = Field( + default=None, + ge=-1, + le=1_000_000, + description="Resource scan limit for EBS snapshots. Use 0 or -1 to disable.", + ) + max_backup_recovery_points: ResourceScanLimit = Field( + default=None, + ge=-1, + le=1_000_000, + description="Resource scan limit for AWS Backup recovery points. Use 0 or -1 to disable.", + ) + max_cloudwatch_log_groups: ResourceScanLimit = Field( + default=None, + ge=-1, + le=1_000_000, + description="Resource scan limit for CloudWatch log groups. Use 0 or -1 to disable.", + ) + max_lambda_functions: ResourceScanLimit = Field( + default=None, + ge=-1, + le=1_000_000, + description="Resource scan limit for Lambda functions. Use 0 or -1 to disable.", + ) + max_ecs_task_definitions: ResourceScanLimit = Field( + default=None, + ge=-1, + le=1_000_000, + description="Resource scan limit for ECS task definitions. Use 0 or -1 to disable.", + ) + max_codeartifact_packages: ResourceScanLimit = Field( + default=None, + ge=-1, + le=1_000_000, + description="Resource scan limit for CodeArtifact packages. Use 0 or -1 to disable.", + ) + # --- IAM --------------------------------------------------------------- mute_non_default_regions: Optional[bool] = None disallowed_regions: Optional[list[str]] = None @@ -394,6 +426,15 @@ class AWSProviderConfig(ProviderConfigBase): # --- Secrets --------------------------------------------------------- secrets_ignore_patterns: Optional[list[str]] = None + secrets_ignore_files: Optional[list[str]] = None + secrets_validate: Optional[bool] = Field( + default=None, + description=( + "Validate discovered secrets against the provider APIs (live check). " + "Makes outbound network calls that authenticate with the discovered " + "secret. Disabled by default." + ), + ) max_days_secret_unused: Optional[int] = Field( default=None, ge=7, @@ -418,5 +459,30 @@ class AWSProviderConfig(ProviderConfigBase): description="Hours of Kinesis stream retention. Range: 24..8760 (1 day .. 1 year).", ) - # --- detect-secrets plugin list ------------------------------------- - detect_secrets_plugins: Optional[list[_DetectSecretsPlugin]] = None + # --- S3 -------------------------------------------------------------- + s3_bucket_object_public_enabled: Optional[bool] = Field( + default=None, + description=( + "Enable the s3_bucket_object_public spot-check, which samples object " + "ACLs per bucket. Disabled by default because it lists and reads object " + "ACLs, which is expensive on large buckets." + ), + ) + s3_bucket_object_public_max_objects: Optional[int] = Field( + default=None, + ge=1, + le=1000, + description=( + "Max objects to list per bucket as the sampling pool. Range: 1..1000 " + "(ListObjectsV2 returns at most 1000 keys per page)." + ), + ) + s3_bucket_object_public_sample_size: Optional[int] = Field( + default=None, + ge=1, + le=1000, + description=( + "Number of objects sampled from the listed pool for ACL inspection. " + "Range: 1..1000. Must be positive to avoid a no-op or invalid sample." + ), + ) diff --git a/prowler/config/schema/okta.py b/prowler/config/schema/okta.py new file mode 100644 index 0000000000..933948f3b6 --- /dev/null +++ b/prowler/config/schema/okta.py @@ -0,0 +1,124 @@ +"""Okta provider config schema with safety bounds.""" + +from typing import Annotated, Optional + +from pydantic import AfterValidator, Field + +from prowler.config.schema.base import ProviderConfigBase + +# Lowest non-zero request rate we accept. Below this a scan is paced so slowly +# it becomes impractical (e.g. 0.001 req/s is ~1000s per request, turning a +# routine scan into days or years). 0 stays valid as the "disable throttling" +# sentinel; anything between 0 and this floor is rejected so a typo can never +# stall a scan. +MIN_REQUESTS_PER_SECOND = 0.1 + + +def _validate_requests_per_second(value: Optional[float]) -> Optional[float]: + """Reject impractically slow non-zero request rates. + + ``0`` (and ``None``) pass through unchanged — ``0`` is the documented + "disable throttling" sentinel. Any positive value below + ``MIN_REQUESTS_PER_SECOND`` is rejected; the ``ge``/``le`` bounds on the + field already handle negatives and the upper cap. + """ + if value is None or value == 0: + return value + if value < MIN_REQUESTS_PER_SECOND: + raise ValueError( + f"must be 0 (disable throttling) or >= {MIN_REQUESTS_PER_SECOND}; " + "smaller rates make scans impractically slow" + ) + return value + + +class OktaProviderConfig(ProviderConfigBase): + """Okta provider configuration schema. + + Bounds the session, idle-timeout and inactivity thresholds consumed by + the Okta checks, plus the provider's API rate-limit handling (proactive + request throttling and the SDK retry safety net). Every field is optional: + when omitted (or dropped for being out of range) the check falls back to + its own DISA STIG-derived default via ``audit_config.get(key, default)``. + """ + + okta_max_session_idle_minutes: Optional[int] = Field( + default=None, + ge=1, + le=1440, + description=( + "Maximum acceptable Global Session idle timeout, in minutes. " + "Range: 1..1440 (DISA STIG V-273186 recommends 15; raising it " + "weakens the idle-timeout control)." + ), + ) + okta_max_session_lifetime_minutes: Optional[int] = Field( + default=None, + ge=1, + le=43200, + description=( + "Maximum acceptable Global Session lifetime, in minutes. " + "Range: 1..43200 i.e. up to 30 days (DISA STIG recommends 18h = " + "1080; raising it weakens the session-lifetime control)." + ), + ) + okta_admin_console_idle_timeout_max_minutes: Optional[int] = Field( + default=None, + ge=1, + le=1440, + description=( + "Maximum acceptable Okta Admin Console app idle timeout, in " + "minutes. Range: 1..1440 (DISA STIG V-273187 recommends 15)." + ), + ) + okta_user_inactivity_max_days: Optional[int] = Field( + default=None, + ge=1, + le=3650, + description=( + "Maximum number of days a user can stay inactive before the " + "inactivity-automation check flags the org. Range: 1..3650 " + "(defaults to 35)." + ), + ) + okta_dod_approved_ca_issuer_patterns: Optional[list[str]] = Field( + default=None, + description=( + "Additional regex patterns matched against a Smart Card IdP " + "certificate issuer DN to recognise a DOD-approved CA. Extends " + "the built-in `OU=DoD` / `OU=ECA` patterns." + ), + ) + + # API rate limiting + okta_requests_per_second: Annotated[ + Optional[float], AfterValidator(_validate_requests_per_second) + ] = Field( + default=None, + ge=0, + le=100, + description=( + "Maximum aggregate Okta API requests per second. Range: 0 or " + f"{MIN_REQUESTS_PER_SECOND}..100 (0 disables throttling). Non-zero " + f"values below {MIN_REQUESTS_PER_SECOND} are rejected to avoid " + "impractically slow scans." + ), + ) + okta_max_retries: Optional[int] = Field( + default=None, + ge=0, + le=10, + description=( + "Max retries on Okta API rate limiting (HTTP 429). Range: 0..10 " + "(0 disables retries)." + ), + ) + okta_request_timeout: Optional[int] = Field( + default=None, + ge=0, + le=3600, + description=( + "Per-request timeout in seconds; also the total budget for the SDK " + "retry loop. Range: 0..3600 (0 disables the timeout)." + ), + ) diff --git a/prowler/config/schema/openstack.py b/prowler/config/schema/openstack.py new file mode 100644 index 0000000000..02b94136ef --- /dev/null +++ b/prowler/config/schema/openstack.py @@ -0,0 +1,34 @@ +"""OpenStack provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class OpenStackProviderConfig(ProviderConfigBase): + """OpenStack provider configuration schema. + + Bounds the image-sharing threshold and reuses the ``secrets_ignore_patterns`` + config consumed by the metadata sensitive-data checks. Every field is + optional: when omitted (or dropped for being out of range) the check falls + back to its own default via ``audit_config.get(key, default)``. + """ + + image_sharing_threshold: Optional[int] = Field( + default=None, + ge=1, + le=1000, + description=( + "Maximum number of accepted project members a shared image may " + "have before being flagged. Range: 1..1000 (defaults to 5)." + ), + ) + secrets_ignore_patterns: Optional[list[str]] = Field( + default=None, + description=( + "Regex patterns whose matches are excluded from secret " + "scanning of resource metadata." + ), + ) diff --git a/prowler/config/schema/registry.py b/prowler/config/schema/registry.py index fa31f1e5eb..d34a9b866a 100644 --- a/prowler/config/schema/registry.py +++ b/prowler/config/schema/registry.py @@ -4,6 +4,7 @@ Kept in its own module so the validator stays free of provider-schema imports and callers pay the import cost only when they actually need the registry. """ +from prowler.config.schema.alibabacloud import AlibabaCloudProviderConfig from prowler.config.schema.aws import AWSProviderConfig from prowler.config.schema.azure import AzureProviderConfig from prowler.config.schema.base import ProviderConfigBase @@ -13,6 +14,8 @@ from prowler.config.schema.github import GitHubProviderConfig from prowler.config.schema.kubernetes import KubernetesProviderConfig from prowler.config.schema.m365 import M365ProviderConfig from prowler.config.schema.mongodbatlas import MongoDBAtlasProviderConfig +from prowler.config.schema.okta import OktaProviderConfig +from prowler.config.schema.openstack import OpenStackProviderConfig from prowler.config.schema.vercel import VercelProviderConfig SCHEMAS: dict[str, type[ProviderConfigBase]] = { @@ -25,4 +28,7 @@ SCHEMAS: dict[str, type[ProviderConfigBase]] = { "mongodbatlas": MongoDBAtlasProviderConfig, "cloudflare": CloudflareProviderConfig, "vercel": VercelProviderConfig, + "okta": OktaProviderConfig, + "alibabacloud": AlibabaCloudProviderConfig, + "openstack": OpenStackProviderConfig, } diff --git a/prowler/lib/check/compliance_config_eval.py b/prowler/lib/check/compliance_config_eval.py new file mode 100644 index 0000000000..763e1389c2 --- /dev/null +++ b/prowler/lib/check/compliance_config_eval.py @@ -0,0 +1,404 @@ +"""Shared evaluation of a requirement's configuration constraints. + +Some compliance requirements only hold if the configurable checks they map to +ran with a configuration strict enough for the requirement. For example CIS AWS +6.0 requirement 2.11 ("credentials unused for 45 days or more are disabled") +maps `iam_user_accesskey_unused` (config `max_unused_access_keys_days`); if the +user loosens that to 120 days the check can PASS while the requirement is, in +fact, not satisfied. + +A requirement declares its expectations via ``ConfigRequirements`` (a list of +``{Check, ConfigKey, Operator, Value}``). The configuration a scan applied is a +single, scan-global mapping (the provider's ``audit_config``), so the rules are +evaluated against that mapping directly. This module is consumed by the SDK +compliance outputs (CSV + CLI table) and by the Prowler App backend so the rule +lives in one place. +""" + +from typing import Any, Optional + +# Leading sentence of the message prepended to a finding's ``status_extended`` +# when its requirement's config constraints are not satisfied and the status is +# forced to FAIL. It opens every config-not-valid message, so it doubles as a +# stable marker for detecting the case programmatically. +CONFIG_NOT_VALID_PREFIX = "Configuration not valid for this requirement." + + +def _format_value(value: Any) -> str: + """Render a constraint value for a user-facing message (lists comma-joined).""" + if isinstance(value, (list, tuple, set)): + return ", ".join(str(item) for item in value) + return str(value) + + +def _describe_violation( + check: Any, config_key: Any, applied: Any, operator: str, expected: Any +) -> str: + """Return a product-friendly explanation of why a config violates a constraint. + + The message names the check and config key, the value the scan applied, what + the requirement needs, and how to fix it, in plain language rather than the + operator/value pair. + + Args: + check: the check the requirement maps to (e.g. ``iam_user_accesskey_unused``). + config_key: the config option that was too loose (e.g. ``max_unused_access_keys_days``). + applied: the value the scan actually applied. + operator: the constraint operator (``lte``/``gte``/``eq``/``in``/``subset``/``superset``). + expected: the value the requirement expects. + + Returns: + A full, human-readable message ending with an actionable fix. + """ + applied_str = _format_value(applied) + expected_str = _format_value(expected) + needs, fix = { + "lte": ( + f"a value of {expected_str} or lower", + f"Update it to {expected_str} or lower.", + ), + "gte": ( + f"a value of {expected_str} or higher", + f"Update it to {expected_str} or higher.", + ), + "eq": ( + f"it set to {expected_str}", + f"Update it to {expected_str}.", + ), + "in": ( + f"it set to one of {expected_str}", + f"Update it to one of {expected_str}.", + ), + "subset": ( + f"it limited to {expected_str}", + f"Remove any value that is not in {expected_str}.", + ), + "superset": ( + f"it to include {expected_str}", + f"Make sure it includes {expected_str}.", + ), + }.get(operator, (f"a different value (expected {operator} {expected_str})", "")) + message = ( + f"{CONFIG_NOT_VALID_PREFIX} The check {check} has {config_key} set to " + f"{applied_str}, but the requirement needs {needs}." + ) + return f"{message} {fix}".strip() + + +def _check_operator(applied: Any, operator: str, expected: Any) -> bool: + """Return whether ``applied`` satisfies ``operator`` against ``expected``.""" + try: + if operator == "lte": + return applied <= expected + if operator == "gte": + return applied >= expected + if operator == "eq": + return applied == expected + if operator == "in": + return applied in expected + if operator in ("subset", "superset"): + # Set comparisons for list-valued configs (allowlists / denylists). + # Both sides must be collections; anything else is not satisfiable. + if not isinstance(applied, (list, tuple, set)) or not isinstance( + expected, (list, tuple, set) + ): + return False + applied_set, expected_set = set(applied), set(expected) + if operator == "subset": + return applied_set <= expected_set + return applied_set >= expected_set + except TypeError: + # Mismatched/unhashable types → treat as not satisfied. + return False + # Unknown operator: do not block the requirement on a malformed constraint. + return True + + +def evaluate_config_constraints( + config_requirements: Optional[list[Any]], + audit_config: Optional[dict[str, Any]], + provider_type: Optional[str] = None, +) -> tuple[bool, str]: + """Evaluate a requirement's config constraints against the scan's config. + + Args: + config_requirements: list of constraints, each a mapping (or object with + the same attributes) holding ``Check``, ``ConfigKey``, ``Operator``, + ``Value`` and an optional ``Provider``. ``None``/empty means the + requirement has no config expectations. + audit_config: the scan-global configuration mapping (the provider's + ``audit_config``, i.e. ``{config_key: value}``). The applied config + is identical across every resource and region of a scan. + provider_type: the provider being scanned (e.g. ``aws``). A constraint + tagged with a ``Provider`` is only evaluated when it matches this + value; this scopes universal (multi-provider) framework constraints + to the right provider. ``None`` disables provider scoping (every + constraint is evaluated), which is the correct behaviour for + single-provider frameworks. + + Returns: + ``(is_compliant, reason)``. ``is_compliant`` is ``True`` when there are + no constraints or every explicitly-set value satisfies its constraint. + When a configured value violates a constraint, returns ``(False, reason)`` + describing the first violation. A constraint whose ``ConfigKey`` was not + explicitly set is skipped (the check's default is assumed to match what + the requirement expects). + """ + if not config_requirements: + return True, "" + + audit_config = audit_config or {} + + for constraint in config_requirements: + # Accept both dicts (API template) and objects (Pydantic model). + if isinstance(constraint, dict): + check = constraint.get("Check") + config_key = constraint.get("ConfigKey") + operator = constraint.get("Operator") + expected = constraint.get("Value") + provider = constraint.get("Provider") + else: + check = getattr(constraint, "Check", None) + config_key = getattr(constraint, "ConfigKey", None) + operator = getattr(constraint, "Operator", None) + expected = getattr(constraint, "Value", None) + provider = getattr(constraint, "Provider", None) + + # Constraint scoped to another provider → not applicable to this scan. + # Compared case-insensitively (and trimmed) so a constraint authored as + # e.g. "AWS" still scopes to the "aws" scan instead of being silently + # bypassed by a casing/format mismatch. + if ( + provider + and provider_type + and str(provider).strip().lower() != str(provider_type).strip().lower() + ): + continue + + if config_key not in audit_config: + # Config not explicitly set → default is assumed adequate. + continue + + applied = audit_config[config_key] + if not _check_operator(applied, operator, expected): + reason = _describe_violation(check, config_key, applied, operator, expected) + return False, reason + + return True, "" + + +def get_scan_audit_config() -> dict[str, Any]: + """Return the scan-global applied configuration (the provider's audit_config). + + The applied config is identical across every resource and region of a scan, + so every compliance output evaluates constraints against this single mapping. + Imported lazily to avoid a circular import with the provider package and to + keep this module usable from contexts without a global provider. + + Returns: + The provider's ``audit_config`` mapping, or ``{}`` when no global + provider is set (``AttributeError``) or the provider package cannot be + imported (``ImportError``). + """ + try: + from prowler.providers.common.provider import Provider + + return Provider.get_global_provider().audit_config or {} + except (AttributeError, ImportError): + # No global provider set, or provider package unavailable. + return {} + + +def get_scan_provider_type() -> str: + """Return the provider being scanned (e.g. ``aws``) for constraint scoping. + + Imported lazily to avoid a circular import with the provider package and to + keep this module usable from contexts without a global provider. + + Returns: + The provider's ``type`` (e.g. ``aws``), or ``""`` when no global provider + is set (``AttributeError``) or the provider package cannot be imported + (``ImportError``); an empty string disables provider scoping. + """ + try: + from prowler.providers.common.provider import Provider + + return Provider.get_global_provider().type or "" + except (AttributeError, ImportError): + # No global provider set, or provider package unavailable. + return "" + + +def _requirement_id(requirement: Any) -> Optional[str]: + """Return a requirement's id across the legacy (``Id``) and universal (``id``) models.""" + return getattr(requirement, "Id", None) or getattr(requirement, "id", None) + + +def _requirement_constraints(requirement: Any) -> Optional[list]: + """Return a requirement's config constraints across both model flavours. + + Legacy ``Compliance_Requirement`` exposes ``ConfigRequirements`` (a list of + Pydantic models); ``UniversalComplianceRequirement`` exposes + ``config_requirements`` (a list of dicts). ``evaluate_config_constraints`` + handles both element types. + """ + return getattr(requirement, "ConfigRequirements", None) or getattr( + requirement, "config_requirements", None + ) + + +def build_requirement_config_status( + requirements: list[Any], + audit_config: Optional[dict[str, Any]] = None, + provider_type: Optional[str] = None, +) -> dict[str, tuple[bool, str]]: + """Map every requirement id to its ``(is_compliant, reason)`` config verdict. + + Only requirements that actually declare constraints are included; callers use + ``dict.get(req_id)`` (returning ``None`` → no constraints → no override). + + Args: + requirements: the framework's requirements (legacy or universal models). + audit_config: the applied config; resolved via ``get_scan_audit_config`` + when omitted. + provider_type: the provider being scanned, for constraint scoping; + resolved via ``get_scan_provider_type`` when omitted. + + Returns: + A mapping ``{requirement_id: (is_compliant, reason)}`` containing only the + requirements that declare config constraints. + """ + if audit_config is None: + audit_config = get_scan_audit_config() + if provider_type is None: + provider_type = get_scan_provider_type() + status = {} + for requirement in requirements: + constraints = _requirement_constraints(requirement) + if constraints: + status[_requirement_id(requirement)] = evaluate_config_constraints( + constraints, audit_config, provider_type + ) + return status + + +def resolve_requirement_config_status( + requirement: Any, + audit_config: dict[str, Any], + cache: dict, + provider_type: Optional[str] = None, +) -> tuple[bool, str]: + """Return a requirement's ``(is_compliant, reason)`` verdict, memoised in ``cache``. + + For table generators that iterate findings × compliances and only encounter + each requirement lazily. + + Args: + requirement: the requirement (legacy or universal model). + audit_config: the scan-global applied config. + cache: a dict keyed by requirement id, reused across the whole table + build to memoise verdicts. + provider_type: the provider being scanned, for constraint scoping; + resolved via ``get_scan_provider_type`` when omitted. + + Returns: + The ``(is_compliant, reason)`` verdict; ``(True, "")`` when the + requirement declares no constraints. + """ + req_id = _requirement_id(requirement) + if req_id not in cache: + constraints = _requirement_constraints(requirement) + if constraints: + if provider_type is None: + provider_type = get_scan_provider_type() + cache[req_id] = evaluate_config_constraints( + constraints, audit_config, provider_type + ) + else: + cache[req_id] = (True, "") + return cache[req_id] + + +def apply_config_status( + status: str, + status_extended: str, + config_status: Optional[tuple[bool, str]], +) -> tuple[str, str]: + """Override a finding's ``(status, status_extended)`` when its config is invalid. + + A requirement whose configurable checks ran with a config too loose to trust + is forced to ``FAIL`` regardless of the finding's own status, with the reason + prepended to ``status_extended``. + + Args: + status: the finding's original status (e.g. ``PASS`` / ``FAIL``). + status_extended: the finding's extended status message. + config_status: the ``(is_compliant, reason)`` tuple from + ``build_requirement_config_status``/``resolve_requirement_config_status``, + or ``None`` when the requirement declares no constraints. + + Returns: + The ``(status, status_extended)`` to report: unchanged when the config is + valid (or ``config_status`` is ``None``); otherwise ``FAIL`` with the + reason prepended to ``status_extended``. + """ + if not config_status or config_status[0]: + return status, status_extended + return ( + "FAIL", + f"{config_status[1]} {status_extended}".strip(), + ) + + +def get_effective_status( + status: str, + config_status: Optional[tuple[bool, str]], +) -> str: + """Return the effective status for table aggregation. + + Args: + status: the finding's original status. + config_status: the ``(is_compliant, reason)`` tuple, or ``None`` when the + requirement declares no constraints. + + Returns: + ``FAIL`` when ``config_status`` marks the config invalid; otherwise the + finding's original ``status``. + """ + if not config_status or config_status[0]: + return status + return "FAIL" + + +def accumulate_overview_status( + index: int, + status: str, + pass_indices: set, + fail_indices: set, + muted_indices: set, +) -> None: + """Record a finding in the overview totals once, with FAIL precedence over PASS (sets mutated in place).""" + if status == "Muted": + muted_indices.add(index) + elif status == "FAIL": + fail_indices.add(index) + pass_indices.discard(index) + elif status == "PASS" and index not in fail_indices: + pass_indices.add(index) + + +def accumulate_group_status( + index: int, + status: str, + counts: dict, + seen: dict, +) -> None: + """Count a finding once per group, upgrading a counted PASS to FAIL on conflict (mutates ``counts``/``seen``).""" + previous = seen.get(index) + if previous is None: + seen[index] = status + counts[status] += 1 + elif previous == "PASS" and status == "FAIL": + seen[index] = "FAIL" + counts["PASS"] -= 1 + counts["FAIL"] += 1 diff --git a/prowler/lib/check/compliance_models.py b/prowler/lib/check/compliance_models.py index f883bf60b1..80322a6929 100644 --- a/prowler/lib/check/compliance_models.py +++ b/prowler/lib/check/compliance_models.py @@ -3,7 +3,7 @@ import json import os import sys from enum import Enum -from typing import Optional, Union +from typing import Any, Literal, Optional, Union from pydantic.v1 import BaseModel, Field, ValidationError, root_validator @@ -170,6 +170,79 @@ class ISO27001_2013_Requirement_Attribute(BaseModel): Check_Summary: str +# Base Compliance Model +class Compliance_Requirement_ConfigConstraint(BaseModel): + """A constraint a requirement places on a configurable check's config. + + Declares that the configurable check ``Check`` must have run with + ``ConfigKey`` satisfying ``Operator`` ``Value`` for the requirement's + result to be trusted. Example: ``max_unused_access_keys_days <= 45``. + + ``Provider`` scopes the constraint to a single provider. It is required for + universal (multi-provider) frameworks, where the same requirement maps + checks across providers and a constraint must only apply when that provider + is the one being scanned. Single-provider frameworks may omit it (the + framework's provider is already the one being scanned). + + Operators: + - ``lte``/``gte``/``eq``: scalar comparisons (e.g. a max-age or min-retention + threshold, or a boolean toggle). + - ``in``: the applied scalar must be one of ``Value`` (a list). + - ``subset``: the applied list must be a subset of ``Value`` — for allowlist + configs (e.g. ``recommended_minimal_tls_versions``); widening the allowlist + with a weaker value (e.g. TLS ``1.0``) breaks the constraint. + - ``superset``: the applied list must be a superset of ``Value`` — for + denylist configs (e.g. ``insecure_key_algorithms``); removing a forbidden + value from the denylist breaks the constraint. + """ + + Check: str + ConfigKey: str + Operator: Literal["lte", "gte", "eq", "in", "subset", "superset"] + # ``bool`` must precede ``int`` so pydantic v1 keeps booleans (e.g. a + # ``mute_non_default_regions == false`` constraint) instead of coercing + # them to 0/1. + Value: Union[bool, int, float, str, list[Any]] + # Provider this constraint applies to (e.g. ``aws``), matched + # case-insensitively. ``None`` applies whenever the requirement runs + # (single-provider frameworks). + Provider: Optional[str] = None + + @root_validator + @classmethod + def validate_value_matches_operator(cls, values): # noqa: F841 + """Ensure ``Value``'s type is consistent with ``Operator``. + + Without this, a mistyped value (e.g. ``gte`` with a list, or ``subset`` + with a scalar) is not rejected at load time and ``_check_operator`` + silently treats it as *not satisfied*, forcing the requirement to a + spurious config-not-valid FAIL. Validating here turns that into a + clear error when the framework is loaded. + """ + operator = values.get("Operator") + value = values.get("Value") + # If Operator/Value failed their own validation they are absent here. + if operator is None or value is None: + return values + if operator in ("in", "subset", "superset"): + if not isinstance(value, list): + raise ValueError( + f"operator '{operator}' requires a list Value, got {type(value).__name__}" + ) + elif operator in ("lte", "gte"): + # bool is an int subclass but is never a valid numeric threshold. + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError( + f"operator '{operator}' requires a numeric Value, got {value!r}" + ) + elif operator == "eq": + if not isinstance(value, (bool, int, float, str)): + raise ValueError( + f"operator 'eq' requires a scalar Value, got {type(value).__name__}" + ) + return values + + # MITRE Requirement Attribute for AWS class Mitre_Requirement_Attribute_AWS(BaseModel): """MITRE Requirement Attribute""" @@ -217,6 +290,9 @@ class Mitre_Requirement(BaseModel): list[Mitre_Requirement_Attribute_GCP], ] Checks: list[str] + # MITRE checks may also declare config constraints; without this field + # Pydantic silently drops them during parsing. + ConfigRequirements: Optional[list[Compliance_Requirement_ConfigConstraint]] = None # KISA-ISMS-P Requirement Attribute @@ -303,7 +379,6 @@ class STIG_Requirement_Attribute(BaseModel): FixText: Optional[str] = None -# Base Compliance Model # TODO: move this to compliance folder class Compliance_Requirement(BaseModel): """Compliance_Requirement holds the base model for every requirement within a compliance framework""" @@ -329,6 +404,7 @@ class Compliance_Requirement(BaseModel): ] ] Checks: list[str] + ConfigRequirements: Optional[list[Compliance_Requirement_ConfigConstraint]] = None class Compliance(BaseModel): @@ -701,6 +777,10 @@ class UniversalComplianceRequirement(BaseModel): name: Optional[str] = None attributes: dict = Field(default_factory=dict) checks: dict[str, list[str]] = Field(default_factory=dict) + # Typed with the same constraint model as legacy so the operator/value + # validation also covers universal frameworks. evaluate_config_constraints + # accepts both dicts and model objects, so downstream consumers are unaffected. + config_requirements: Optional[list[Compliance_Requirement_ConfigConstraint]] = None tactics: Optional[list] = None sub_techniques: Optional[list] = None platforms: Optional[list] = None @@ -894,6 +974,11 @@ def adapt_legacy_to_universal(legacy: Compliance) -> ComplianceFramework: # For MITRE, promote special fields and store raw attributes raw_attrs = [attr.dict() for attr in req.Attributes] attrs = {"_raw_attributes": raw_attrs} + config_requirements = ( + [c.dict() for c in req.ConfigRequirements] + if getattr(req, "ConfigRequirements", None) + else None + ) universal_requirements.append( UniversalComplianceRequirement( id=req.Id, @@ -901,6 +986,7 @@ def adapt_legacy_to_universal(legacy: Compliance) -> ComplianceFramework: name=req.Name, attributes=attrs, checks=req_checks, + config_requirements=config_requirements, tactics=req.Tactics, sub_techniques=req.SubTechniques, platforms=req.Platforms, @@ -913,6 +999,11 @@ def adapt_legacy_to_universal(legacy: Compliance) -> ComplianceFramework: attrs = req.Attributes[0].dict() else: attrs = {} + config_requirements = ( + [c.dict() for c in req.ConfigRequirements] + if getattr(req, "ConfigRequirements", None) + else None + ) universal_requirements.append( UniversalComplianceRequirement( id=req.Id, @@ -920,6 +1011,7 @@ def adapt_legacy_to_universal(legacy: Compliance) -> ComplianceFramework: name=req.Name, attributes=attrs, checks=req_checks, + config_requirements=config_requirements, ) ) diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index b65a702fbc..67a7ea2824 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -473,6 +473,18 @@ Detailed documentation at https://docs.prowler.com default=default_fixer_config_file_path, help="Set configuration fixer file path", ) + config_parser.add_argument( + "--scan-secrets-validate", + action="store_true", + default=False, + help=( + "Validate secrets discovered by the secrets checks by checking " + "whether they are live against the provider APIs. WARNING: this " + "makes outbound network calls using the discovered secret itself; " + "the credential is exercised against the provider and the call " + "appears in the audited account's logs. Disabled by default." + ), + ) def __init_custom_checks_metadata_parser__(self): # CustomChecksMetadata 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 df23aeb1d1..074e684f33 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 @@ -2,6 +2,13 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + accumulate_group_status, + accumulate_overview_status, + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) def get_asd_essential_eight_table( @@ -19,11 +26,13 @@ def get_asd_essential_eight_table( "Status": [], "Muted": [], } - pass_count = [] - fail_count = [] - muted_count = [] + pass_count = set() + fail_count = set() + muted_count = set() section_seen = {} provider = "" + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance @@ -31,6 +40,12 @@ def get_asd_essential_eight_table( if compliance.Framework == "ASD-Essential-Eight": provider = compliance.Provider for requirement in compliance.Requirements: + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) for attribute in requirement.Attributes: section = attribute.Section if section not in sections: @@ -39,29 +54,15 @@ def get_asd_essential_eight_table( "PASS": 0, "Muted": 0, } - section_seen[section] = set() + section_seen[section] = {} - # 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-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 + status = "Muted" if finding.muted else effective_status + accumulate_overview_status( + index, status, pass_count, fail_count, muted_count + ) + accumulate_group_status( + index, status, sections[section], section_seen[section] + ) sections = dict(sorted(sections.items())) for section in sections: diff --git a/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_aws.py b/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_aws.py index c264c81505..4bdf9cd851 100644 --- a/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_aws.py +++ b/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_aws.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.asd_essential_eight.models import ( ASDEssentialEightAWSModel, @@ -36,10 +40,19 @@ class ASDEssentialEightAWS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = ASDEssentialEightAWSModel( Provider=finding.provider, @@ -63,8 +76,8 @@ class ASDEssentialEightAWS(ComplianceOutput): Requirements_Attributes_AuditProcedure=attribute.AuditProcedure, Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_References=attribute.References, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/aws_well_architected/aws_well_architected.py b/prowler/lib/outputs/compliance/aws_well_architected/aws_well_architected.py index 4a157b0324..69a37949f8 100644 --- a/prowler/lib/outputs/compliance/aws_well_architected/aws_well_architected.py +++ b/prowler/lib/outputs/compliance/aws_well_architected/aws_well_architected.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.aws_well_architected.models import ( AWSWellArchitectedModel, @@ -36,10 +40,18 @@ class AWSWellArchitected(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AWSWellArchitectedModel( Provider=finding.provider, @@ -58,8 +70,8 @@ class AWSWellArchitected(ComplianceOutput): Requirements_Attributes_AssessmentMethod=attribute.AssessmentMethod, Requirements_Attributes_Description=attribute.Description, Requirements_Attributes_ImplementationGuidanceUrl=attribute.ImplementationGuidanceUrl, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/c5/c5.py b/prowler/lib/outputs/compliance/c5/c5.py index b48260b5a2..003b2664cf 100644 --- a/prowler/lib/outputs/compliance/c5/c5.py +++ b/prowler/lib/outputs/compliance/c5/c5.py @@ -2,6 +2,13 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + accumulate_group_status, + accumulate_overview_status, + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) def get_c5_table( @@ -18,12 +25,14 @@ def get_c5_table( "Status": [], "Muted": [], } - pass_count = [] - fail_count = [] - muted_count = [] + pass_count = set() + fail_count = set() + muted_count = set() sections = {} section_seen = {} provider = "" + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance @@ -31,34 +40,26 @@ def get_c5_table( if compliance.Framework == "C5": provider = compliance.Provider for requirement in compliance.Requirements: + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) 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() + section_seen[section] = {} - # 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-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 + status = "Muted" if finding.muted else effective_status + accumulate_overview_status( + index, status, pass_count, fail_count, muted_count + ) + accumulate_group_status( + index, status, sections[section], section_seen[section] + ) sections = dict(sorted(sections.items())) for section in sections: diff --git a/prowler/lib/outputs/compliance/c5/c5_aws.py b/prowler/lib/outputs/compliance/c5/c5_aws.py index 5f13b77d7a..b8a4115482 100644 --- a/prowler/lib/outputs/compliance/c5/c5_aws.py +++ b/prowler/lib/outputs/compliance/c5/c5_aws.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.c5.models import AWSC5Model from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,10 +38,19 @@ class AWSC5(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AWSC5Model( Provider=finding.provider, @@ -52,8 +65,8 @@ class AWSC5(ComplianceOutput): Requirements_Attributes_Type=attribute.Type, Requirements_Attributes_AboutCriteria=attribute.AboutCriteria, Requirements_Attributes_ComplementaryCriteria=attribute.ComplementaryCriteria, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/c5/c5_azure.py b/prowler/lib/outputs/compliance/c5/c5_azure.py index 1b3a9e6b90..0899ff6b15 100644 --- a/prowler/lib/outputs/compliance/c5/c5_azure.py +++ b/prowler/lib/outputs/compliance/c5/c5_azure.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.c5.models import AzureC5Model from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,10 +38,19 @@ class AzureC5(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AzureC5Model( Provider=finding.provider, @@ -52,8 +65,8 @@ class AzureC5(ComplianceOutput): Requirements_Attributes_Type=attribute.Type, Requirements_Attributes_AboutCriteria=attribute.AboutCriteria, Requirements_Attributes_ComplementaryCriteria=attribute.ComplementaryCriteria, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/c5/c5_gcp.py b/prowler/lib/outputs/compliance/c5/c5_gcp.py index 84e66a141f..c8b874f622 100644 --- a/prowler/lib/outputs/compliance/c5/c5_gcp.py +++ b/prowler/lib/outputs/compliance/c5/c5_gcp.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.c5.models import GCPC5Model from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,10 +38,19 @@ class GCPC5(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = GCPC5Model( Provider=finding.provider, @@ -52,8 +65,8 @@ class GCPC5(ComplianceOutput): Requirements_Attributes_Type=attribute.Type, Requirements_Attributes_AboutCriteria=attribute.AboutCriteria, Requirements_Attributes_ComplementaryCriteria=attribute.ComplementaryCriteria, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/ccc/ccc.py b/prowler/lib/outputs/compliance/ccc/ccc.py index d8d76ad2d9..48b2086e78 100644 --- a/prowler/lib/outputs/compliance/ccc/ccc.py +++ b/prowler/lib/outputs/compliance/ccc/ccc.py @@ -2,6 +2,13 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + accumulate_group_status, + accumulate_overview_status, + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) def get_ccc_table( @@ -18,12 +25,14 @@ def get_ccc_table( "Status": [], "Muted": [], } - pass_count = [] - fail_count = [] - muted_count = [] + pass_count = set() + fail_count = set() + muted_count = set() sections = {} section_seen = {} provider = "" + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance @@ -31,34 +40,26 @@ def get_ccc_table( if compliance.Framework == "CCC": provider = compliance.Provider for requirement in compliance.Requirements: + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) 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() + section_seen[section] = {} - # 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-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 + status = "Muted" if finding.muted else effective_status + accumulate_overview_status( + index, status, pass_count, fail_count, muted_count + ) + accumulate_group_status( + index, status, sections[section], section_seen[section] + ) sections = dict(sorted(sections.items())) for section in sections: diff --git a/prowler/lib/outputs/compliance/ccc/ccc_aws.py b/prowler/lib/outputs/compliance/ccc/ccc_aws.py index 1114425574..20d42b1508 100644 --- a/prowler/lib/outputs/compliance/ccc/ccc_aws.py +++ b/prowler/lib/outputs/compliance/ccc/ccc_aws.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.ccc.models import CCC_AWSModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,10 +38,19 @@ class CCC_AWS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = CCC_AWSModel( Provider=finding.provider, @@ -56,8 +69,8 @@ class CCC_AWS(ComplianceOutput): Requirements_Attributes_Recommendation=attribute.Recommendation, Requirements_Attributes_SectionThreatMappings=attribute.SectionThreatMappings, Requirements_Attributes_SectionGuidelineMappings=attribute.SectionGuidelineMappings, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/ccc/ccc_azure.py b/prowler/lib/outputs/compliance/ccc/ccc_azure.py index e0cdf32330..4dcc8c5ca8 100644 --- a/prowler/lib/outputs/compliance/ccc/ccc_azure.py +++ b/prowler/lib/outputs/compliance/ccc/ccc_azure.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.ccc.models import CCC_AzureModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,10 +38,19 @@ class CCC_Azure(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = CCC_AzureModel( Provider=finding.provider, @@ -56,8 +69,8 @@ class CCC_Azure(ComplianceOutput): Requirements_Attributes_Recommendation=attribute.Recommendation, Requirements_Attributes_SectionThreatMappings=attribute.SectionThreatMappings, Requirements_Attributes_SectionGuidelineMappings=attribute.SectionGuidelineMappings, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/ccc/ccc_gcp.py b/prowler/lib/outputs/compliance/ccc/ccc_gcp.py index 326a15e5c3..ed7c709c24 100644 --- a/prowler/lib/outputs/compliance/ccc/ccc_gcp.py +++ b/prowler/lib/outputs/compliance/ccc/ccc_gcp.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.ccc.models import CCC_GCPModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,10 +38,19 @@ class CCC_GCP(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = CCC_GCPModel( Provider=finding.provider, @@ -56,8 +69,8 @@ class CCC_GCP(ComplianceOutput): Requirements_Attributes_Recommendation=attribute.Recommendation, Requirements_Attributes_SectionThreatMappings=attribute.SectionThreatMappings, Requirements_Attributes_SectionGuidelineMappings=attribute.SectionGuidelineMappings, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cis/cis.py b/prowler/lib/outputs/compliance/cis/cis.py index 4acbd7fe58..de3120f689 100644 --- a/prowler/lib/outputs/compliance/cis/cis.py +++ b/prowler/lib/outputs/compliance/cis/cis.py @@ -2,6 +2,13 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + accumulate_group_status, + accumulate_overview_status, + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) def get_cis_table( @@ -23,9 +30,11 @@ def get_cis_table( "Level 2": [], "Muted": [], } - pass_count = [] - fail_count = [] - muted_count = [] + pass_count = set() + fail_count = set() + muted_count = set() + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance @@ -34,6 +43,12 @@ def get_cis_table( if compliance.Framework == "CIS" and version_in_name in compliance.Version: provider = compliance.Provider for requirement in compliance.Requirements: + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) for attribute in requirement.Attributes: section = attribute.Section # Check if Section exists @@ -46,43 +61,37 @@ def get_cis_table( } section_muted_seen[section] = set() section_split_seen[section] = { - "Level 1": set(), - "Level 2": set(), + "Level 1": {}, + "Level 2": {}, } + + status = "Muted" if finding.muted else effective_status + accumulate_overview_status( + index, status, pass_count, fail_count, muted_count + ) 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: - fail_count.append(index) - elif finding.status == "PASS" and index not in pass_count: - pass_count.append(index) + if "Level 1" in attribute.Profile: - 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 + if not finding.muted: + accumulate_group_status( + index, + effective_status, + sections[section]["Level 1"], + section_split_seen[section]["Level 1"], + ) elif "Level 2" in attribute.Profile: - 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: - sections[section]["Level 2"]["PASS"] += 1 + if not finding.muted: + accumulate_group_status( + index, + effective_status, + sections[section]["Level 2"], + section_split_seen[section]["Level 2"], + ) # Add results to table sections = dict(sorted(sections.items())) diff --git a/prowler/lib/outputs/compliance/cis/cis_alibabacloud.py b/prowler/lib/outputs/compliance/cis/cis_alibabacloud.py index a1880d3af9..77bf02ee98 100644 --- a/prowler/lib/outputs/compliance/cis/cis_alibabacloud.py +++ b/prowler/lib/outputs/compliance/cis/cis_alibabacloud.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import AlibabaCloudCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,10 +38,18 @@ class AlibabaCloudCIS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AlibabaCloudCISModel( Provider=finding.provider, @@ -59,8 +71,8 @@ class AlibabaCloudCIS(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_DefaultValue=attribute.DefaultValue, Requirements_Attributes_References=attribute.References, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cis/cis_aws.py b/prowler/lib/outputs/compliance/cis/cis_aws.py index ac0250150c..58bf7fbc27 100644 --- a/prowler/lib/outputs/compliance/cis/cis_aws.py +++ b/prowler/lib/outputs/compliance/cis/cis_aws.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import AWSCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,10 +38,19 @@ class AWSCIS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AWSCISModel( Provider=finding.provider, @@ -59,8 +72,8 @@ class AWSCIS(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_DefaultValue=attribute.DefaultValue, Requirements_Attributes_References=attribute.References, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cis/cis_azure.py b/prowler/lib/outputs/compliance/cis/cis_azure.py index b1e09ca453..53b1cbdd32 100644 --- a/prowler/lib/outputs/compliance/cis/cis_azure.py +++ b/prowler/lib/outputs/compliance/cis/cis_azure.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import AzureCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,10 +38,18 @@ class AzureCIS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AzureCISModel( Provider=finding.provider, @@ -59,8 +71,8 @@ class AzureCIS(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_DefaultValue=attribute.DefaultValue, Requirements_Attributes_References=attribute.References, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cis/cis_gcp.py b/prowler/lib/outputs/compliance/cis/cis_gcp.py index b2889333be..c2a11dae77 100644 --- a/prowler/lib/outputs/compliance/cis/cis_gcp.py +++ b/prowler/lib/outputs/compliance/cis/cis_gcp.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import GCPCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,10 +38,18 @@ class GCPCIS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = GCPCISModel( Provider=finding.provider, @@ -58,8 +70,8 @@ class GCPCIS(ComplianceOutput): Requirements_Attributes_AuditProcedure=attribute.AuditProcedure, Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_References=attribute.References, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cis/cis_github.py b/prowler/lib/outputs/compliance/cis/cis_github.py index 2ce5b64480..039bd1b993 100644 --- a/prowler/lib/outputs/compliance/cis/cis_github.py +++ b/prowler/lib/outputs/compliance/cis/cis_github.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import GithubCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,10 +38,18 @@ class GithubCIS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = GithubCISModel( Provider=finding.provider, @@ -58,8 +70,8 @@ class GithubCIS(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_References=attribute.References, Requirements_Attributes_DefaultValue=attribute.DefaultValue, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cis/cis_googleworkspace.py b/prowler/lib/outputs/compliance/cis/cis_googleworkspace.py index d74375bf85..cf2d3755c7 100644 --- a/prowler/lib/outputs/compliance/cis/cis_googleworkspace.py +++ b/prowler/lib/outputs/compliance/cis/cis_googleworkspace.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import GoogleWorkspaceCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,10 +38,18 @@ class GoogleWorkspaceCIS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = GoogleWorkspaceCISModel( Provider=finding.provider, @@ -58,8 +70,8 @@ class GoogleWorkspaceCIS(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_DefaultValue=attribute.DefaultValue, Requirements_Attributes_References=attribute.References, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cis/cis_kubernetes.py b/prowler/lib/outputs/compliance/cis/cis_kubernetes.py index 9607123e44..23c482310e 100644 --- a/prowler/lib/outputs/compliance/cis/cis_kubernetes.py +++ b/prowler/lib/outputs/compliance/cis/cis_kubernetes.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import KubernetesCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,10 +38,18 @@ class KubernetesCIS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = KubernetesCISModel( Provider=finding.provider, @@ -59,8 +71,8 @@ class KubernetesCIS(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_References=attribute.References, Requirements_Attributes_DefaultValue=attribute.DefaultValue, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cis/cis_m365.py b/prowler/lib/outputs/compliance/cis/cis_m365.py index 1a00166946..8dc06155e7 100644 --- a/prowler/lib/outputs/compliance/cis/cis_m365.py +++ b/prowler/lib/outputs/compliance/cis/cis_m365.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import M365CISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,10 +38,18 @@ class M365CIS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = M365CISModel( Provider=finding.provider, @@ -59,8 +71,8 @@ class M365CIS(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_DefaultValue=attribute.DefaultValue, Requirements_Attributes_References=attribute.References, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cis/cis_oraclecloud.py b/prowler/lib/outputs/compliance/cis/cis_oraclecloud.py index 117c3ca004..d8110d72db 100644 --- a/prowler/lib/outputs/compliance/cis/cis_oraclecloud.py +++ b/prowler/lib/outputs/compliance/cis/cis_oraclecloud.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import OracleCloudCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,10 +38,18 @@ class OracleCloudCIS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = OracleCloudCISModel( Provider=finding.provider, @@ -59,8 +71,8 @@ class OracleCloudCIS(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_DefaultValue=attribute.DefaultValue, Requirements_Attributes_References=attribute.References, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cisa_scuba/cisa_scuba_googleworkspace.py b/prowler/lib/outputs/compliance/cisa_scuba/cisa_scuba_googleworkspace.py index 9f1336e832..d2f6faa212 100644 --- a/prowler/lib/outputs/compliance/cisa_scuba/cisa_scuba_googleworkspace.py +++ b/prowler/lib/outputs/compliance/cisa_scuba/cisa_scuba_googleworkspace.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cisa_scuba.models import ( GoogleWorkspaceCISASCuBAModel, @@ -36,10 +40,18 @@ class GoogleWorkspaceCISASCuBA(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = GoogleWorkspaceCISASCuBAModel( Provider=finding.provider, @@ -52,8 +64,8 @@ class GoogleWorkspaceCISASCuBA(ComplianceOutput): Requirements_Attributes_SubSection=attribute.SubSection, Requirements_Attributes_Service=attribute.Service, Requirements_Attributes_Type=attribute.Type, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/ens/ens.py b/prowler/lib/outputs/compliance/ens/ens.py index c39abe0a6a..4fcca922c7 100644 --- a/prowler/lib/outputs/compliance/ens/ens.py +++ b/prowler/lib/outputs/compliance/ens/ens.py @@ -2,6 +2,11 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) def get_ens_table( @@ -25,9 +30,11 @@ def get_ens_table( "Opcional": [], "Muted": [], } - pass_count = [] - fail_count = [] - muted_count = [] + pass_count = set() + fail_count = set() + muted_count = set() + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance @@ -35,6 +42,12 @@ def get_ens_table( if compliance.Framework == "ENS": provider = compliance.Provider for requirement in compliance.Requirements: + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) for attribute in requirement.Attributes: marco_categoria = f"{attribute.Marco}/{attribute.Categoria}" # Check if Marco/Categoria exists @@ -50,25 +63,24 @@ def get_ens_table( 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) + muted_count.add(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 effective_status == "FAIL": if attribute.Tipo != "recomendacion": - if index not in fail_count: - fail_count.append(index) + fail_count.add(index) + pass_count.discard(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}" - elif finding.status == "PASS" and index not in pass_count: - pass_count.append(index) + elif effective_status == "PASS" and index not in fail_count: + pass_count.add(index) if attribute.Nivel == "opcional": marcos[marco_categoria]["Opcional"] += 1 elif attribute.Nivel == "alto": diff --git a/prowler/lib/outputs/compliance/ens/ens_aws.py b/prowler/lib/outputs/compliance/ens/ens_aws.py index 40d185294b..543799f7b9 100644 --- a/prowler/lib/outputs/compliance/ens/ens_aws.py +++ b/prowler/lib/outputs/compliance/ens/ens_aws.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.ens.models import AWSENSModel @@ -34,10 +38,19 @@ class AWSENS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AWSENSModel( Provider=finding.provider, @@ -60,8 +73,8 @@ class AWSENS(ComplianceOutput): Requirements_Attributes_Dependencias=",".join( attribute.Dependencias ), - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/ens/ens_azure.py b/prowler/lib/outputs/compliance/ens/ens_azure.py index 20d727fed0..23055da2a2 100644 --- a/prowler/lib/outputs/compliance/ens/ens_azure.py +++ b/prowler/lib/outputs/compliance/ens/ens_azure.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.ens.models import AzureENSModel @@ -34,10 +38,19 @@ class AzureENS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AzureENSModel( Provider=finding.provider, @@ -60,8 +73,8 @@ class AzureENS(ComplianceOutput): Requirements_Attributes_Dependencias=",".join( attribute.Dependencias ), - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/ens/ens_gcp.py b/prowler/lib/outputs/compliance/ens/ens_gcp.py index 8a2baaca66..f616e48f83 100644 --- a/prowler/lib/outputs/compliance/ens/ens_gcp.py +++ b/prowler/lib/outputs/compliance/ens/ens_gcp.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.ens.models import GCPENSModel @@ -34,10 +38,19 @@ class GCPENS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = GCPENSModel( Provider=finding.provider, @@ -60,8 +73,8 @@ class GCPENS(ComplianceOutput): Requirements_Attributes_Dependencias=",".join( attribute.Dependencias ), - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/generic/generic.py b/prowler/lib/outputs/compliance/generic/generic.py index b774f09577..b3f1ad7ec9 100644 --- a/prowler/lib/outputs/compliance/generic/generic.py +++ b/prowler/lib/outputs/compliance/generic/generic.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.generic.models import GenericComplianceModel @@ -35,11 +39,24 @@ class GenericCompliance(ComplianceOutput): - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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. + status, status_extended = ( + apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) + if finding + else ("MANUAL", "Manual check") + ) return GenericComplianceModel( Provider=(finding.provider if finding else compliance.Provider.lower()), Description=compliance.Description, @@ -56,8 +73,8 @@ class GenericCompliance(ComplianceOutput): 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"), + Status=status, + StatusExtended=status_extended, 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", diff --git a/prowler/lib/outputs/compliance/generic/generic_table.py b/prowler/lib/outputs/compliance/generic/generic_table.py index 9136cf19e2..060acda10a 100644 --- a/prowler/lib/outputs/compliance/generic/generic_table.py +++ b/prowler/lib/outputs/compliance/generic/generic_table.py @@ -2,6 +2,11 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) def get_generic_compliance_table( @@ -15,6 +20,8 @@ def get_generic_compliance_table( pass_count = [] fail_count = [] muted_count = [] + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance @@ -25,13 +32,21 @@ def get_generic_compliance_table( and compliance.Version in compliance_framework.upper() and compliance.Provider.upper() in compliance_framework.upper() ): - if finding.muted: - if index not in muted_count: - muted_count.append(index) - else: - if finding.status == "FAIL" and index not in fail_count: + for requirement in compliance.Requirements: + # A configurable check that passed with a too-loose config is + # forced to FAIL (source of truth: framework ConfigRequirements). + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) + if finding.muted: + if index not in muted_count: + muted_count.append(index) + elif effective_status == "FAIL" and index not in fail_count: fail_count.append(index) - elif finding.status == "PASS" and index not in pass_count: + elif effective_status == "PASS" and index not in pass_count: pass_count.append(index) if ( len(fail_count) + len(pass_count) + len(muted_count) > 1 diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_aws.py b/prowler/lib/outputs/compliance/iso27001/iso27001_aws.py index 282f27a218..3376b8c5ad 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_aws.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_aws.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.iso27001.models import AWSISO27001Model @@ -34,10 +38,18 @@ class AWSISO27001(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AWSISO27001Model( Provider=finding.provider, @@ -52,8 +64,8 @@ class AWSISO27001(ComplianceOutput): Requirements_Attributes_Objetive_ID=attribute.Objetive_ID, Requirements_Attributes_Objetive_Name=attribute.Objetive_Name, Requirements_Attributes_Check_Summary=attribute.Check_Summary, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, CheckId=finding.check_id, Muted=finding.muted, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_azure.py b/prowler/lib/outputs/compliance/iso27001/iso27001_azure.py index 0112bbd7e1..f1f964c726 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_azure.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_azure.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.iso27001.models import AzureISO27001Model @@ -34,10 +38,18 @@ class AzureISO27001(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AzureISO27001Model( Provider=finding.provider, @@ -52,8 +64,8 @@ class AzureISO27001(ComplianceOutput): Requirements_Attributes_Objetive_ID=attribute.Objetive_ID, Requirements_Attributes_Objetive_Name=attribute.Objetive_Name, Requirements_Attributes_Check_Summary=attribute.Check_Summary, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, CheckId=finding.check_id, Muted=finding.muted, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_gcp.py b/prowler/lib/outputs/compliance/iso27001/iso27001_gcp.py index f60a30e67e..9a5de17bfa 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_gcp.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_gcp.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.iso27001.models import GCPISO27001Model @@ -34,10 +38,18 @@ class GCPISO27001(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = GCPISO27001Model( Provider=finding.provider, @@ -52,8 +64,8 @@ class GCPISO27001(ComplianceOutput): Requirements_Attributes_Objetive_ID=attribute.Objetive_ID, Requirements_Attributes_Objetive_Name=attribute.Objetive_Name, Requirements_Attributes_Check_Summary=attribute.Check_Summary, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, CheckId=finding.check_id, Muted=finding.muted, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py b/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py index 59fd593ce6..5ca81e82d5 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.iso27001.models import KubernetesISO27001Model @@ -34,10 +38,18 @@ class KubernetesISO27001(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = KubernetesISO27001Model( Provider=finding.provider, @@ -52,8 +64,8 @@ class KubernetesISO27001(ComplianceOutput): Requirements_Attributes_Objetive_ID=attribute.Objetive_ID, Requirements_Attributes_Objetive_Name=attribute.Objetive_Name, Requirements_Attributes_Check_Summary=attribute.Check_Summary, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, CheckId=finding.check_id, Muted=finding.muted, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_m365.py b/prowler/lib/outputs/compliance/iso27001/iso27001_m365.py index cf6712a4a6..84c5072002 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_m365.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_m365.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.iso27001.models import M365ISO27001Model @@ -34,10 +38,18 @@ class M365ISO27001(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = M365ISO27001Model( Provider=finding.provider, @@ -52,8 +64,8 @@ class M365ISO27001(ComplianceOutput): Requirements_Attributes_Objetive_ID=attribute.Objetive_ID, Requirements_Attributes_Objetive_Name=attribute.Objetive_Name, Requirements_Attributes_Check_Summary=attribute.Check_Summary, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, CheckId=finding.check_id, Muted=finding.muted, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_nhn.py b/prowler/lib/outputs/compliance/iso27001/iso27001_nhn.py index 2ad5a0bda4..ad8ea6dcab 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_nhn.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_nhn.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.iso27001.models import NHNISO27001Model @@ -34,10 +38,18 @@ class NHNISO27001(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = NHNISO27001Model( Provider=finding.provider, @@ -52,8 +64,8 @@ class NHNISO27001(ComplianceOutput): Requirements_Attributes_Objetive_ID=attribute.Objetive_ID, Requirements_Attributes_Objetive_Name=attribute.Objetive_Name, Requirements_Attributes_Check_Summary=attribute.Check_Summary, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, CheckId=finding.check_id, Muted=finding.muted, diff --git a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py index e7c00b188d..dda83342b6 100644 --- a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py +++ b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py @@ -2,6 +2,12 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + accumulate_overview_status, + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) def get_kisa_ismsp_table( @@ -22,9 +28,11 @@ def get_kisa_ismsp_table( "Status": [], "Muted": [], } - pass_count = [] - fail_count = [] - muted_count = [] + pass_count = set() + fail_count = set() + muted_count = set() + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance @@ -35,6 +43,12 @@ def get_kisa_ismsp_table( ): provider = compliance.Provider for requirement in compliance.Requirements: + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) for attribute in requirement.Attributes: section = attribute.Section # Check if Section exists @@ -46,29 +60,27 @@ def get_kisa_ismsp_table( }, "Muted": 0, } - section_seen[section] = set() + section_seen[section] = {} - # 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) + status = "Muted" if finding.muted else effective_status + accumulate_overview_status( + index, status, pass_count, fail_count, muted_count + ) - # 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: + # FAIL/PASS live under ["Status"], Muted at top level. + previous = section_seen[section].get(index) + if previous is None: + section_seen[section][index] = status + if status == "Muted": sections[section]["Muted"] += 1 - elif finding.status == "FAIL": + elif status == "FAIL": sections[section]["Status"]["FAIL"] += 1 - elif finding.status == "PASS": + elif status == "PASS": sections[section]["Status"]["PASS"] += 1 + elif previous == "PASS" and status == "FAIL": + section_seen[section][index] = "FAIL" + sections[section]["Status"]["PASS"] -= 1 + sections[section]["Status"]["FAIL"] += 1 # Add results to table sections = dict(sorted(sections.items())) diff --git a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws.py b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws.py index f927c2d4b1..3d05632d26 100644 --- a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws.py +++ b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.kisa_ismsp.models import AWSKISAISMSPModel @@ -34,10 +38,19 @@ class AWSKISAISMSP(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AWSKISAISMSPModel( Provider=finding.provider, @@ -55,8 +68,8 @@ class AWSKISAISMSP(ComplianceOutput): Requirements_Attributes_RelatedRegulations=attribute.RelatedRegulations, Requirements_Attributes_AuditEvidence=attribute.AuditEvidence, Requirements_Attributes_NonComplianceCases=attribute.NonComplianceCases, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py index 7492624aff..a352acfa6e 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py +++ b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py @@ -2,6 +2,13 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + accumulate_group_status, + accumulate_overview_status, + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) def get_mitre_attack_table( @@ -21,9 +28,11 @@ def get_mitre_attack_table( "Status": [], "Muted": [], } - pass_count = [] - fail_count = [] - muted_count = [] + pass_count = set() + fail_count = set() + muted_count = set() + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance @@ -34,32 +43,23 @@ def get_mitre_attack_table( ): provider = compliance.Provider for requirement in compliance.Requirements: + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) + status = "Muted" if finding.muted else effective_status 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 - elif finding.status == "FAIL": - tactics[tactic]["FAIL"] += 1 - elif finding.status == "PASS": - tactics[tactic]["PASS"] += 1 + tactic_seen[tactic] = {} + accumulate_overview_status( + index, status, pass_count, fail_count, muted_count + ) + accumulate_group_status( + index, status, tactics[tactic], tactic_seen[tactic] + ) # Add results to table tactics = dict(sorted(tactics.items())) for tactic in tactics: diff --git a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_aws.py b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_aws.py index 33a7aca74b..92f4ac0e5b 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_aws.py +++ b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_aws.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.mitre_attack.models import AWSMitreAttackModel @@ -35,10 +39,19 @@ class AWSMitreAttack(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) compliance_row = AWSMitreAttackModel( Provider=finding.provider, Description=compliance.Description, @@ -66,8 +79,8 @@ class AWSMitreAttack(ComplianceOutput): Requirements_Attributes_Comments=", ".join( attribute.Comment for attribute in requirement.Attributes ), - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_azure.py b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_azure.py index 0254aad86e..a43e27e775 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_azure.py +++ b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_azure.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.mitre_attack.models import AzureMitreAttackModel @@ -35,10 +39,19 @@ class AzureMitreAttack(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) compliance_row = AzureMitreAttackModel( Provider=finding.provider, Description=compliance.Description, @@ -67,8 +80,8 @@ class AzureMitreAttack(ComplianceOutput): Requirements_Attributes_Comments=", ".join( attribute.Comment for attribute in requirement.Attributes ), - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_gcp.py b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_gcp.py index 4634273494..b8efdc5250 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_gcp.py +++ b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_gcp.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.mitre_attack.models import GCPMitreAttackModel @@ -35,10 +39,19 @@ class GCPMitreAttack(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) compliance_row = GCPMitreAttackModel( Provider=finding.provider, Description=compliance.Description, @@ -66,8 +79,8 @@ class GCPMitreAttack(ComplianceOutput): Requirements_Attributes_Comments=", ".join( attribute.Comment for attribute in requirement.Attributes ), - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py index 1febe02f60..ef6bb2742a 100644 --- a/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py +++ b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py @@ -2,6 +2,11 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) def get_okta_idaas_stig_table( @@ -24,6 +29,8 @@ def get_okta_idaas_stig_table( sections = {} section_seen = {} provider = "" + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance @@ -31,6 +38,14 @@ def get_okta_idaas_stig_table( if compliance.Framework == "Okta-IDaaS-STIG": provider = compliance.Provider for requirement in compliance.Requirements: + # A configurable check that passed with a too-loose config is + # forced to FAIL (source of truth: framework ConfigRequirements). + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) for attribute in requirement.Attributes: section = attribute.Section @@ -42,10 +57,10 @@ def get_okta_idaas_stig_table( if finding.muted: if index not in muted_count: muted_count.append(index) - elif finding.status == "FAIL": + elif effective_status == "FAIL": if index not in fail_count: fail_count.append(index) - elif finding.status == "PASS": + elif effective_status == "PASS": if index not in pass_count: pass_count.append(index) @@ -55,9 +70,9 @@ def get_okta_idaas_stig_table( section_seen[section].add(index) if finding.muted: sections[section]["Muted"] += 1 - elif finding.status == "FAIL": + elif effective_status == "FAIL": sections[section]["FAIL"] += 1 - elif finding.status == "PASS": + elif effective_status == "PASS": sections[section]["PASS"] += 1 sections = dict(sorted(sections.items())) diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py index 25f71b4def..b8a72f9f95 100644 --- a/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py +++ b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.okta_idaas_stig.models import OktaIDaaSSTIGModel @@ -34,10 +38,18 @@ class OktaIDaaSSTIG(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = OktaIDaaSSTIGModel( Provider=finding.provider, @@ -54,8 +66,8 @@ class OktaIDaaSSTIG(ComplianceOutput): Requirements_Attributes_CCI=attribute.CCI, Requirements_Attributes_CheckText=attribute.CheckText, Requirements_Attributes_FixText=attribute.FixText, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py index b17307f04a..cb23d67ffb 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py @@ -2,6 +2,13 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + accumulate_group_status, + accumulate_overview_status, + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance @@ -20,18 +27,20 @@ def get_prowler_threatscore_table( "Score": [], "Muted": [], } - pass_count = [] - fail_count = [] - muted_count = [] + pass_count = set() + fail_count = set() + muted_count = set() pillars = {} pillar_seen = {} provider = "" generic_score = 0 max_generic_score = 0 - counted_findings_generic = [] + counted_findings_generic = {} score_per_pillar = {} max_score_per_pillar = {} counted_findings_per_pillar = {} + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance @@ -39,6 +48,12 @@ def get_prowler_threatscore_table( if compliance.Framework == "ProwlerThreatScore": provider = compliance.Provider for requirement in compliance.Requirements: + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) for attribute in requirement.Attributes: pillar = attribute.Section @@ -51,57 +66,51 @@ def get_prowler_threatscore_table( ): score_per_pillar[pillar] = 0 max_score_per_pillar[pillar] = 0 - counted_findings_per_pillar[pillar] = [] + counted_findings_per_pillar[pillar] = {} - if ( - index not in counted_findings_per_pillar[pillar] - and not finding.muted - ): - if finding.status == "PASS": - score_per_pillar[pillar] += ( - attribute.LevelOfRisk * attribute.Weight - ) - max_score_per_pillar[pillar] += ( - attribute.LevelOfRisk * attribute.Weight - ) - counted_findings_per_pillar[pillar].append(index) + # Revoke an earlier PASS score if a later requirement FAILs. + if not finding.muted: + contribution = attribute.LevelOfRisk * attribute.Weight + counted = counted_findings_per_pillar[pillar] + if index not in counted: + max_score_per_pillar[pillar] += contribution + if effective_status == "PASS": + score_per_pillar[pillar] += contribution + counted[index] = contribution + else: + counted[index] = 0 + elif effective_status == "FAIL" and counted[index]: + score_per_pillar[pillar] -= counted[index] + counted[index] = 0 if pillar not in pillars: pillars[pillar] = {"FAIL": 0, "PASS": 0, "Muted": 0} - pillar_seen[pillar] = set() + pillar_seen[pillar] = {} - # 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) + status = "Muted" if finding.muted else effective_status + accumulate_overview_status( + index, status, pass_count, fail_count, muted_count + ) + accumulate_group_status( + index, status, pillars[pillar], pillar_seen[pillar] + ) - # 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 - if index not in counted_findings_generic and not finding.muted: - if finding.status == "PASS": - generic_score += ( - attribute.LevelOfRisk * attribute.Weight - ) - max_generic_score += ( - attribute.LevelOfRisk * attribute.Weight - ) - counted_findings_generic.append(index) + # Generic score, with the same PASS-revocation on FAIL. + if not finding.muted: + contribution = attribute.LevelOfRisk * attribute.Weight + if index not in counted_findings_generic: + max_generic_score += contribution + if effective_status == "PASS": + generic_score += contribution + counted_findings_generic[index] = contribution + else: + counted_findings_generic[index] = 0 + elif ( + effective_status == "FAIL" + and counted_findings_generic[index] + ): + generic_score -= counted_findings_generic[index] + counted_findings_generic[index] = 0 no_findings_pillars = [] bulk_compliance = ( diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_alibaba.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_alibaba.py index 510c098dff..7d682a3e62 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_alibaba.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_alibaba.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.prowler_threatscore.models import ( @@ -36,10 +40,19 @@ class ProwlerThreatScoreAlibaba(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = ProwlerThreatScoreAlibabaModel( Provider=finding.provider, @@ -56,8 +69,8 @@ class ProwlerThreatScoreAlibaba(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, Requirements_Attributes_Weight=attribute.Weight, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws.py index ae992f8a75..f1280808e3 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.prowler_threatscore.models import ( @@ -36,10 +40,19 @@ class ProwlerThreatScoreAWS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = ProwlerThreatScoreAWSModel( Provider=finding.provider, @@ -56,8 +69,8 @@ class ProwlerThreatScoreAWS(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, Requirements_Attributes_Weight=attribute.Weight, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure.py index dd0a3b9a56..5118511369 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.prowler_threatscore.models import ( @@ -36,10 +40,19 @@ class ProwlerThreatScoreAzure(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = ProwlerThreatScoreAzureModel( Provider=finding.provider, @@ -56,8 +69,8 @@ class ProwlerThreatScoreAzure(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, Requirements_Attributes_Weight=attribute.Weight, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp.py index c3bad98ade..39f9c23850 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.prowler_threatscore.models import ( @@ -36,10 +40,19 @@ class ProwlerThreatScoreGCP(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = ProwlerThreatScoreGCPModel( Provider=finding.provider, @@ -56,8 +69,8 @@ class ProwlerThreatScoreGCP(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, Requirements_Attributes_Weight=attribute.Weight, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_kubernetes.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_kubernetes.py index 51f88348f0..88bf41582a 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_kubernetes.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_kubernetes.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.prowler_threatscore.models import ( @@ -36,10 +40,19 @@ class ProwlerThreatScoreKubernetes(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = ProwlerThreatScoreKubernetesModel( Provider=finding.provider, @@ -56,8 +69,8 @@ class ProwlerThreatScoreKubernetes(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, Requirements_Attributes_Weight=attribute.Weight, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365.py index d0b2ad635c..b7b533c72a 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.prowler_threatscore.models import ( @@ -36,10 +40,19 @@ class ProwlerThreatScoreM365(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + 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: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = ProwlerThreatScoreM365Model( Provider=finding.provider, @@ -56,8 +69,8 @@ class ProwlerThreatScoreM365(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, Requirements_Attributes_Weight=attribute.Weight, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/universal/ocsf_compliance.py b/prowler/lib/outputs/compliance/universal/ocsf_compliance.py index 2886f7e4d2..07c1f67b10 100644 --- a/prowler/lib/outputs/compliance/universal/ocsf_compliance.py +++ b/prowler/lib/outputs/compliance/universal/ocsf_compliance.py @@ -19,6 +19,10 @@ from py_ocsf_models.objects.product import Product from py_ocsf_models.objects.resource_details import ResourceDetails from prowler.config.config import prowler_version +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import ComplianceFramework from prowler.lib.logger import logger from prowler.lib.outputs.utils import unroll_dict_to_list @@ -181,11 +185,21 @@ class OCSFComplianceOutput: for check_id in all_checks: check_req_map.setdefault(check_id, []).append(req) + # Scope constraints to this output's provider (e.g. an Azure constraint + # must not affect an AWS output). + requirement_config_status = build_requirement_config_status( + framework.requirements, provider_type=self._provider + ) + for finding in findings: if finding.check_id in check_req_map: for req in check_req_map[finding.check_id]: cf = self._build_compliance_finding( - finding, framework, req, compliance_name + finding, + framework, + req, + compliance_name, + requirement_config_status.get(req.id, (True, "")), ) if cf: self._data.append(cf) @@ -240,10 +254,14 @@ class OCSFComplianceOutput: framework: ComplianceFramework, requirement, compliance_name: str, + config_status: tuple = (True, ""), ) -> ComplianceFinding: try: + effective_status, message = apply_config_status( + finding.status, finding.status_extended, config_status + ) compliance_status = PROWLER_TO_COMPLIANCE_STATUS.get( - finding.status, ComplianceStatusID.Unknown + effective_status, ComplianceStatusID.Unknown ) check_status = PROWLER_TO_COMPLIANCE_STATUS.get( finding.status, ComplianceStatusID.Unknown @@ -272,6 +290,7 @@ class OCSFComplianceOutput: requirements=[requirement.id], control=requirement.description, status_id=compliance_status, + # Nested Check preserves the raw check result. checks=[ Check( uid=finding.check_id, @@ -293,7 +312,7 @@ class OCSFComplianceOutput: else None ), ), - message=finding.status_extended, + message=message, metadata=Metadata( event_code=finding.check_id, product=Product( @@ -339,8 +358,10 @@ class OCSFComplianceOutput: severity=finding_severity.name, status_id=event_status.value, status=event_status.name, - status_code=finding.status, - status_detail=finding.status_extended, + # Effective status, so the top-level never contradicts the + # nested compliance status. + status_code=effective_status, + status_detail=message, time=time_value, time_dt=( finding.timestamp diff --git a/prowler/lib/outputs/compliance/universal/universal_output.py b/prowler/lib/outputs/compliance/universal/universal_output.py index a3cdb1389a..59afd1fa31 100644 --- a/prowler/lib/outputs/compliance/universal/universal_output.py +++ b/prowler/lib/outputs/compliance/universal/universal_output.py @@ -5,6 +5,10 @@ from typing import TYPE_CHECKING, Optional from pydantic.v1 import create_model from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import ComplianceFramework from prowler.lib.logger import logger from prowler.lib.utils.utils import open_file @@ -134,7 +138,9 @@ class UniversalComplianceOutput: return " | ".join(str(v) for v in value) return value - def _build_row(self, finding, framework, requirement, is_manual=False): + def _build_row( + self, finding, framework, requirement, is_manual=False, config_status=None + ): """Build a single row dict for a finding + requirement combination.""" row = { "Provider": ( @@ -180,10 +186,14 @@ class UniversalComplianceOutput: ) row["Requirements_TechniqueURL"] = requirement.technique_url - row["Status"] = finding.status if not is_manual else "MANUAL" - row["StatusExtended"] = ( - finding.status_extended if not is_manual else "Manual check" - ) + if is_manual: + row["Status"] = "MANUAL" + row["StatusExtended"] = "Manual check" + else: + # Config-invalid PASS reports as FAIL, matching OCSF/table outputs. + row["Status"], row["StatusExtended"] = apply_config_status( + finding.status, finding.status_extended, config_status + ) row["ResourceId"] = finding.resource_uid if not is_manual else "manual_check" row["ResourceName"] = finding.resource_name if not is_manual else "Manual check" row["CheckId"] = finding.check_id if not is_manual else "manual" @@ -222,6 +232,12 @@ class UniversalComplianceOutput: check_req_map[check_id] = [] check_req_map[check_id].append(req) + # Scope constraints to this output's provider (e.g. an Azure constraint + # must not affect an AWS output). + requirement_config_status = build_requirement_config_status( + framework.requirements, provider_type=self._provider + ) + # Process findings using the provider-filtered check_req_map. # This ensures that for multi-provider dict checks, only the checks # belonging to the current provider produce output rows. @@ -229,7 +245,12 @@ class UniversalComplianceOutput: check_id = finding.check_id if check_id in check_req_map: for req in check_req_map[check_id]: - row = self._build_row(finding, framework, req) + row = self._build_row( + finding, + framework, + req, + config_status=requirement_config_status.get(req.id), + ) try: self._data.append(self._row_model(**row)) except Exception as e: diff --git a/prowler/lib/outputs/compliance/universal/universal_table.py b/prowler/lib/outputs/compliance/universal/universal_table.py index e54ad5155c..5f4a6cf88b 100644 --- a/prowler/lib/outputs/compliance/universal/universal_table.py +++ b/prowler/lib/outputs/compliance/universal/universal_table.py @@ -2,6 +2,13 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + accumulate_group_status, + accumulate_overview_status, + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) from prowler.lib.check.compliance_models import ComplianceFramework @@ -164,9 +171,11 @@ def _render_grouped( check_map = _build_requirement_check_map(framework, provider) groups = {} group_seen = {} - pass_count = [] - fail_count = [] - muted_count = [] + pass_count = set() + fail_count = set() + muted_count = set() + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check_id = finding.check_metadata.CheckID @@ -174,32 +183,24 @@ def _render_grouped( continue for req in check_map[check_id]: + effective_status = get_effective_status( + finding.status, + resolve_requirement_config_status( + req, audit_config, config_status_cache, provider_type=provider + ), + ) 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() + group_seen[group_key] = {} - # 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-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 + status = "Muted" if finding.muted else effective_status + accumulate_overview_status( + index, status, pass_count, fail_count, muted_count + ) + accumulate_group_status( + index, status, groups[group_key], group_seen[group_key] + ) if not _print_overview( pass_count, fail_count, muted_count, compliance_framework_name, labels @@ -272,9 +273,11 @@ def _render_split( groups = {} group_muted_seen = {} group_split_seen = {} - pass_count = [] - fail_count = [] - muted_count = [] + pass_count = set() + fail_count = set() + muted_count = set() + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check_id = finding.check_metadata.CheckID @@ -282,6 +285,12 @@ def _render_split( continue for req in check_map[check_id]: + effective_status = get_effective_status( + finding.status, + resolve_requirement_config_status( + req, audit_config, config_status_cache, provider_type=provider + ), + ) for group_key in _get_group_key(req, group_by): if group_key not in groups: groups[group_key] = { @@ -289,33 +298,33 @@ def _render_split( } groups[group_key]["Muted"] = 0 group_muted_seen[group_key] = set() - group_split_seen[group_key] = {sv: set() for sv in split_values} + group_split_seen[group_key] = {sv: {} 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) + muted_count.add(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: - fail_count.append(index) - elif finding.status == "PASS" and index not in pass_count: - pass_count.append(index) + if effective_status == "FAIL": + fail_count.add(index) + pass_count.discard(index) + elif effective_status == "PASS" and index not in fail_count: + pass_count.add(index) for sv in split_values: if sv in str(split_val): - 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: - groups[group_key][sv]["PASS"] += 1 + accumulate_group_status( + index, + effective_status, + groups[group_key][sv], + group_split_seen[group_key][sv], + ) if not _print_overview( pass_count, fail_count, muted_count, compliance_framework_name, labels @@ -387,16 +396,18 @@ def _render_scored( weight_field = scoring.weight_field groups = {} group_seen = {} - pass_count = [] - fail_count = [] - muted_count = [] + pass_count = set() + fail_count = set() + muted_count = set() score_per_group = {} max_score_per_group = {} counted_per_group = {} generic_score = 0 max_generic_score = 0 - counted_generic = [] + counted_generic = {} + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check_id = finding.check_metadata.CheckID @@ -404,6 +415,12 @@ def _render_scored( continue for req in check_map[check_id]: + effective_status = get_effective_status( + finding.status, + resolve_requirement_config_status( + req, audit_config, config_status_cache, provider_type=provider + ), + ) for group_key in _get_group_key(req, group_by): attrs = req.attributes risk = attrs.get(risk_field, 0) @@ -411,44 +428,47 @@ def _render_scored( if group_key not in groups: groups[group_key] = {"FAIL": 0, "PASS": 0, "Muted": 0} - group_seen[group_key] = set() + group_seen[group_key] = {} score_per_group[group_key] = 0 max_score_per_group[group_key] = 0 - counted_per_group[group_key] = [] + counted_per_group[group_key] = {} - if index not in counted_per_group[group_key] and not finding.muted: - if finding.status == "PASS": - score_per_group[group_key] += risk * weight - max_score_per_group[group_key] += risk * weight - counted_per_group[group_key].append(index) + # Revoke an earlier PASS score if a later requirement FAILs. + if not finding.muted: + contribution = risk * weight + counted = counted_per_group[group_key] + if index not in counted: + max_score_per_group[group_key] += contribution + if effective_status == "PASS": + score_per_group[group_key] += contribution + counted[index] = contribution + else: + counted[index] = 0 + elif effective_status == "FAIL" and counted[index]: + score_per_group[group_key] -= counted[index] + counted[index] = 0 - # 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) + status = "Muted" if finding.muted else effective_status + accumulate_overview_status( + index, status, pass_count, fail_count, muted_count + ) + accumulate_group_status( + index, status, groups[group_key], group_seen[group_key] + ) - # 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: - if finding.status == "PASS": - generic_score += risk * weight - max_generic_score += risk * weight - counted_generic.append(index) + # Generic score, with the same PASS-revocation on FAIL. + if not finding.muted: + contribution = risk * weight + if index not in counted_generic: + max_generic_score += contribution + if effective_status == "PASS": + generic_score += contribution + counted_generic[index] = contribution + else: + counted_generic[index] = 0 + elif effective_status == "FAIL" and counted_generic[index]: + generic_score -= counted_generic[index] + counted_generic[index] = 0 if not _print_overview( pass_count, fail_count, muted_count, compliance_framework_name, labels diff --git a/prowler/lib/resource_limit.py b/prowler/lib/resource_limit.py new file mode 100644 index 0000000000..da144e9dd0 --- /dev/null +++ b/prowler/lib/resource_limit.py @@ -0,0 +1,88 @@ +"""Scoped resource scan limits for high-volume resources. + +Some services accumulate huge numbers of resources (EBS snapshots, backup +recovery points, log groups, Lambda functions, ECS task definitions, +CodeArtifact packages). Scanning all of them causes API throttling, slow +scans, cost and noisy findings. + +``get_resource_scan_limit`` resolves the configured number of resources to +analyze for a supported resource path. A limited resource can produce zero, +one, or many findings; findings are not capped or re-ordered here. + +Tradeoff: for newest-based resources, services may need to list lightweight or +base metadata broadly to select the truly newest resources, then apply limits +only to expensive hydration or analysis. The helper must not send +user-configured limits as unsafe paginator ``PageSize`` values because AWS +services validate page sizes differently. +""" + +from collections.abc import Callable, Iterable, Iterator, Mapping +from itertools import islice +from typing import Any, Optional, Protocol, TypeVar + +GLOBAL_LIMIT_KEY = "max_scanned_resources_per_service" +T = TypeVar("T") + + +class PaginatorProtocol(Protocol): + """Minimal boto3-compatible paginator interface used by this module.""" + + def paginate(self, **operation_parameters: Any) -> Iterable[Mapping[str, Any]]: + """Return paginator pages for the provided operation parameters.""" + + +def get_resource_scan_limit(audit_config: dict, service_key: str) -> Optional[int]: + """Resolve the resource scan limit for a service. + + Precedence: per-service key (``service_key``) > global + ``max_scanned_resources_per_service`` > unlimited. + + A non-positive resolved value means **unlimited** (``None``), preserving + the legacy behavior as an explicit opt-out. + + Args: + audit_config: The provider ``audit_config`` dictionary. + service_key: The per-service config key, e.g. ``max_lambda_functions``. + + Returns: + The limit as a positive ``int``, or ``None`` for unlimited. + """ + value = audit_config.get(service_key) + if value is None: + value = audit_config.get(GLOBAL_LIMIT_KEY) + if value is None or value <= 0: + return None + return int(value) + + +def limit_resources(resources: Iterable[T], limit: Optional[int]) -> Iterator[T]: + """Yield up to ``limit`` resources without changing resource order.""" + if not limit or limit <= 0: + yield from resources + return + yield from islice(resources, limit) + + +def iter_limited_paginator_items( + paginator: PaginatorProtocol, + result_key: str, + limit: Optional[int], + item_filter: Optional[Callable[[T], bool]] = None, + **operation_parameters: Any, +) -> Iterator[T]: + """Yield paginator result items, stopping after ``limit`` selected items. + + The configured resource-analysis limit is intentionally not sent as + ``PageSize`` because AWS services validate page sizes differently. The + paginator receives only the operation parameters needed by the AWS API, + while this iterator applies the analysis limit defensively client-side. + """ + selected = 0 + for page in paginator.paginate(**operation_parameters): + for item in page.get(result_key, []): + if item_filter and not item_filter(item): + continue + yield item + selected += 1 + if limit and selected >= limit: + return diff --git a/prowler/lib/utils/utils.py b/prowler/lib/utils/utils.py index c4b29f6cc1..62e01d66ef 100644 --- a/prowler/lib/utils/utils.py +++ b/prowler/lib/utils/utils.py @@ -9,52 +9,116 @@ except ImportError: pass import re +import shutil +import subprocess import sys import tempfile from datetime import datetime -from hashlib import sha512 +from functools import lru_cache +from hashlib import sha1, sha512 from io import TextIOWrapper from ipaddress import ip_address from os.path import exists from time import mktime -from typing import Any, Optional +from typing import Any, Iterable, Mapping, Optional, Union from colorama import Style -from detect_secrets import SecretsCollection -from detect_secrets.settings import transient_settings from prowler.config.config import encoding_format_utf_8 from prowler.lib.logger import logger -default_detect_secrets_plugins = [ - {"name": "ArtifactoryDetector"}, - {"name": "AWSKeyDetector"}, - {"name": "AzureStorageKeyDetector"}, - {"name": "BasicAuthDetector"}, - {"name": "CloudantDetector"}, - {"name": "DiscordBotTokenDetector"}, - {"name": "GitHubTokenDetector"}, - {"name": "GitLabTokenDetector"}, - {"name": "Base64HighEntropyString", "limit": 6.0}, - {"name": "HexHighEntropyString", "limit": 3.0}, - {"name": "IbmCloudIamDetector"}, - {"name": "IbmCosHmacDetector"}, - # {"name": "IPPublicDetector"}, https://github.com/Yelp/detect-secrets/pull/885 - {"name": "JwtTokenDetector"}, - {"name": "KeywordDetector"}, - {"name": "MailchimpDetector"}, - {"name": "NpmDetector"}, - {"name": "OpenAIDetector"}, - {"name": "PrivateKeyDetector"}, - {"name": "PypiTokenDetector"}, - {"name": "SendGridDetector"}, - {"name": "SlackDetector"}, - {"name": "SoftlayerDetector"}, - {"name": "SquareOAuthDetector"}, - {"name": "StripeDetector"}, - # {"name": "TelegramBotTokenDetector"}, https://github.com/Yelp/detect-secrets/pull/878 - {"name": "TwilioKeyDetector"}, -] +# Default minimum confidence level for reporting findings. "low" is required to +# enable Kingfisher's built-in generic rules (Generic Password / Secret / API +# Key), which preserve the keyword-based coverage Prowler had with +# detect-secrets' KeywordDetector; at "medium" those generic rules do not fire. +# Possible values: "low", "medium", "high". +default_secrets_confidence = "low" + +# Kingfisher exit codes considered successful: 0 (no findings), 200 (findings), +# 205 (validated findings). +_kingfisher_success_exit_codes = (0, 200, 205) + +# Number of payloads scanned per Kingfisher invocation in batch mode. Bounds +# peak temp-disk and memory while still amortizing the per-process spawn cost +# across many fragments (see detect_secrets_scan_batch). +default_secrets_batch_chunk_size = 500 + +# Wall-clock cap (seconds) for a single Kingfisher subprocess, so a hung binary +# cannot block the audit indefinitely. +default_secrets_scan_timeout = 300 + + +class SecretsScanError(Exception): + """The secret scanner could not produce a trustworthy result. + + Raised when Kingfisher exits with a non-success code, times out, cannot be + located/executed, or returns output that cannot be parsed. This is distinct + from "no secrets found": a security check must never treat a scanner failure + as a clean result, so callers are expected to surface it as ``MANUAL`` + (manual review required) instead of ``PASS``. + """ + + +@lru_cache(maxsize=1) +def get_kingfisher_binary() -> str: + """Return the path to the bundled Kingfisher binary (cached).""" + from kingfisher import get_binary_path + + return get_binary_path() + + +def _build_kingfisher_command( + scan_paths: list, + output_path: str, + confidence: str, + validate: bool, + no_dedup: bool = False, +) -> list: + """Build the Kingfisher ``scan`` command shared by single and batch scans.""" + command = [ + get_kingfisher_binary(), + "scan", + *scan_paths, + "--format", + "json", + "--output", + output_path, + "--no-update-check", + "--confidence", + confidence, + ] + if validate: + # Live-validate discovered secrets against provider APIs. Use + # conservative defaults (short timeout, no retries) to limit the blast + # radius of the outbound calls. + command += ["--validation-timeout", "5", "--validation-retries", "0"] + else: + command.append("--no-validate") + if no_dedup: + # Report every occurrence (one per file) so batched results match + # scanning each payload individually. + command.append("--no-dedup") + return command + + +def _finding_to_dict(entry: dict, fallback_filename: str) -> dict: + """Convert a Kingfisher finding entry into Prowler's finding dict shape.""" + rule = entry.get("rule", {}) + finding = entry.get("finding", {}) + snippet = finding.get("snippet", "") or "" + return { + "filename": finding.get("path", fallback_filename), + "line_number": finding.get("line"), + "type": rule.get("name"), + # Non-security identifier for the matched secret (matches the + # detect-secrets output shape); not used for security. + "hashed_secret": ( + sha1(snippet.encode(), usedforsecurity=False).hexdigest() + if snippet + else None + ), + "is_verified": finding.get("validation", {}).get("status") == "Active", + } def open_file(input_file: str, mode: str = "r") -> TextIOWrapper: @@ -111,77 +175,188 @@ def hash_sha512(string: str) -> str: return sha512(string.encode(encoding_format_utf_8)).hexdigest()[0:9] -def detect_secrets_scan( - data: str = None, - file=None, - excluded_secrets: list[str] = None, - detect_secrets_plugins: dict = None, -) -> list[dict[str, str]]: - """detect_secrets_scan scans the data or file for secrets using the detect-secrets library. - Args: - data (str): The data to scan for secrets. - file (str): The file to scan for secrets. - excluded_secrets (list): A list of regex patterns to exclude from the scan. - detect_secrets_plugins (dict): The settings to use for the scan. - Returns: - dict: The secrets found in the - Raises: - Exception: If an error occurs during the scan. - Examples: - >>> detect_secrets_scan(data="password=password") - [{'filename': 'data', 'hashed_secret': 'f7c3bc1d808e04732adf679965ccc34ca7ae3441', 'is_verified': False, 'line_number': 1, 'type': 'Secret Keyword'}] - >>> detect_secrets_scan(file="file.txt") - {'file.txt': [{'filename': 'file.txt', 'hashed_secret': 'f7c3bc1d808e04732adf679965ccc34ca7ae3441', 'is_verified': False, 'line_number': 1, 'type': 'Secret Keyword'}]} +def _scan_batch_chunk( + chunk: list, + excluded_secrets: list, + confidence: str, + validate: bool, + results: dict, +) -> None: + """Scan one chunk of ``(key, data)`` payloads in a single Kingfisher call. + + Writes each payload to its own file in a temp directory, scans the whole + directory once (``--no-dedup`` so per-file results match individual scans), + maps findings back to their key by file path, and appends them to + ``results``. The temp directory is always removed. """ + if not chunk: + return + tmp_dir = tempfile.mkdtemp() + temp_output_file = None try: - if not file: - temp_data_file = tempfile.NamedTemporaryFile(delete=False) - temp_data_file.write(bytes(data, encoding="raw_unicode_escape")) - temp_data_file.close() + index_to_key = {} + for index, (key, data) in enumerate(chunk): + content = data if data.endswith("\n") else data + "\n" + name = str(index) + with open(os.path.join(tmp_dir, name), "wb") as fh: + fh.write(bytes(content, encoding="raw_unicode_escape")) + index_to_key[name] = key - secrets = SecretsCollection() - - if not detect_secrets_plugins: - detect_secrets_plugins = default_detect_secrets_plugins - - settings = { - "plugins_used": detect_secrets_plugins, - "filters_used": [ - {"path": "detect_secrets.filters.common.is_invalid_file"}, - {"path": "detect_secrets.filters.common.is_known_false_positive"}, - {"path": "detect_secrets.filters.heuristic.is_likely_id_string"}, - {"path": "detect_secrets.filters.heuristic.is_potential_secret"}, - ], - } - - if excluded_secrets and len(excluded_secrets) > 0: - settings["filters_used"].append( - { - "path": "detect_secrets.filters.regex.should_exclude_line", - "pattern": excluded_secrets, - } + temp_output_file = tempfile.NamedTemporaryFile(delete=False, suffix=".json") + temp_output_file.close() + command = _build_kingfisher_command( + [tmp_dir], temp_output_file.name, confidence, validate, no_dedup=True + ) + process = subprocess.run( + command, + capture_output=True, + text=True, + timeout=default_secrets_scan_timeout, + ) + if process.returncode not in _kingfisher_success_exit_codes: + raise SecretsScanError( + f"Kingfisher exited with code {process.returncode}: " + f"{process.stderr.strip()[:500]}" ) - with transient_settings(settings): - if file: - secrets.scan_file(file) - else: - secrets.scan_file(temp_data_file.name) - if not file: - os.remove(temp_data_file.name) + with open(temp_output_file.name, encoding=encoding_format_utf_8) as f: + output = f.read() + kingfisher_output = json.loads(output) if output.strip() else {} - detect_secrets_output = secrets.json() + source_lines_cache = {} - if detect_secrets_output: - if file: - return detect_secrets_output[file] - else: - return detect_secrets_output[temp_data_file.name] - else: - return None - except Exception as e: - logger.error(f"Error scanning for secrets: {e}") - return None + def source_lines(file_name: str) -> list: + if file_name not in source_lines_cache: + with open( + os.path.join(tmp_dir, file_name), + encoding=encoding_format_utf_8, + errors="replace", + ) as f: + source_lines_cache[file_name] = f.read().splitlines() + return source_lines_cache[file_name] + + for entry in kingfisher_output.get("findings", []): + finding = entry.get("finding", {}) + name = os.path.basename(finding.get("path", "")) + key = index_to_key.get(name) + if key is None: + continue + # Validate the line index before any consumer trusts it. Checks use + # ``line_number`` as a 1-based index into their own parallel data + # (e.g. CloudWatch does ``events[line_number - 1]``), so a missing, + # non-integer, or out-of-range line would crash the check or map the + # secret to the wrong resource. Fail closed: surface a malformed + # finding as a scan failure so callers report MANUAL instead of a + # wrong PASS/FAIL. ``bool`` is rejected explicitly because it is a + # subclass of ``int``. + line_number = finding.get("line") + lines = source_lines(name) + if ( + isinstance(line_number, bool) + or not isinstance(line_number, int) + or not 1 <= line_number <= len(lines) + ): + raise SecretsScanError( + f"Kingfisher returned an invalid line number " + f"{line_number!r} for a finding in {name}" + ) + if excluded_secrets and any( + re.search(pattern, lines[line_number - 1]) + for pattern in excluded_secrets + ): + continue + results.setdefault(key, []).append(_finding_to_dict(entry, name)) + except SecretsScanError: + # Already a typed scan failure; propagate so callers report MANUAL. + raise + except subprocess.TimeoutExpired as error: + raise SecretsScanError( + f"Kingfisher timed out after {default_secrets_scan_timeout}s " + "while scanning for secrets" + ) from error + except Exception as error: + # Fail closed: a missing/unexecutable binary, unparseable JSON output or + # any other runtime failure must NOT be silently treated as "no secrets + # found". Surface it so callers can report MANUAL instead of PASS. + raise SecretsScanError(f"Secret scan failed: {error}") from error + finally: + if temp_output_file and os.path.exists(temp_output_file.name): + os.remove(temp_output_file.name) + shutil.rmtree(tmp_dir, ignore_errors=True) + + +def detect_secrets_scan_batch( + payloads: Union[Mapping[Any, str], Iterable[tuple[Any, str]]], + excluded_secrets: Optional[list[str]] = None, + confidence: str = default_secrets_confidence, + validate: bool = False, + chunk_size: int = default_secrets_batch_chunk_size, +) -> dict: + """Scan many payloads with Kingfisher in chunked subprocess invocations. + + This is the scan entry point used by every secret check. Each payload is + written to its own file and scanned with ``--no-dedup`` so per-payload + results match scanning each payload on its own. Payloads are processed in + chunks (writing each to disk and releasing it as it is consumed) to bound + peak temp-disk and memory use while amortizing the per-process spawn cost + across many fragments. + + By default the scan runs fully offline (``--no-validate``, + ``--no-update-check``): no network calls are made, so the scanned data is + never sent anywhere. When ``validate`` is True, Kingfisher additionally + checks whether each discovered secret is live by authenticating with it + against the provider's API (the secret itself is the credential; no extra + permissions are required). That makes outbound network calls, so it must be + explicitly opted in. + + Args: + payloads: a mapping ``{key: data}`` or any iterable of ``(key, data)`` + pairs. ``key`` is any hashable the caller uses to map findings back + to its source (e.g. a variable name or a ``(resource, stream)``). + excluded_secrets (list): regex patterns; a finding whose source line + matches one is excluded. + confidence (str): minimum Kingfisher confidence ("low"/"medium"/"high"). + validate (bool): live-validate discovered secrets (outbound calls). + chunk_size (int): payloads scanned per Kingfisher invocation. + Returns: + dict mapping each key that produced findings to its list of finding + dicts, each with ``filename``, ``line_number``, ``type``, + ``hashed_secret`` and ``is_verified`` keys. Keys with no findings are + omitted. + Raises: + SecretsScanError: if the scanner fails for any chunk (non-success exit + code, timeout, missing/unexecutable binary or unparseable output). + An empty result is therefore always "no secrets found", never a + silent scan failure; callers must report MANUAL on this error. + """ + items = payloads.items() if hasattr(payloads, "items") else payloads + results = {} + chunk = [] + for key, data in items: + chunk.append((key, data)) + if len(chunk) >= chunk_size: + _scan_batch_chunk(chunk, excluded_secrets, confidence, validate, results) + chunk = [] + _scan_batch_chunk(chunk, excluded_secrets, confidence, validate, results) + return results + + +def annotate_verified_secrets(report, secrets: list) -> None: + """Escalate and annotate a finding when any of its secrets is confirmed live. + + When secret validation (``--scan-secrets-validate`` / ``secrets_validate``) + confirms that a discovered secret is live, the finding is more severe than a + potential secret: its severity is raised to critical and a note is appended + to ``status_extended``. No-op when no secret was validated as live, so the + default offline behavior (and existing finding messages) is unchanged. + """ + if secrets and any(secret.get("is_verified") for secret in secrets): + from prowler.lib.check.models import Severity + + report.check_metadata.Severity = Severity.critical + report.status_extended += ( + " One or more of these secrets were confirmed to be live." + ) def validate_ip_address(ip_string): diff --git a/prowler/providers/alibabacloud/alibabacloud_provider.py b/prowler/providers/alibabacloud/alibabacloud_provider.py index 82e48e2f14..d7020186a0 100644 --- a/prowler/providers/alibabacloud/alibabacloud_provider.py +++ b/prowler/providers/alibabacloud/alibabacloud_provider.py @@ -53,6 +53,7 @@ class AlibabacloudProvider(Provider): """ _type: str = "alibabacloud" + sdk_only: bool = False _identity: AlibabaCloudIdentityInfo _session: AlibabaCloudSession _audit_resources: list = [] diff --git a/prowler/providers/aws/aws_provider.py b/prowler/providers/aws/aws_provider.py index cf6cbdd73a..b4c9ed3771 100644 --- a/prowler/providers/aws/aws_provider.py +++ b/prowler/providers/aws/aws_provider.py @@ -90,6 +90,7 @@ class AwsProvider(Provider): """ _type: str = "aws" + sdk_only: bool = False _identity: AWSIdentityInfo _session: AWSSession _organizations_metadata: AWSOrganizationsInfo diff --git a/prowler/providers/aws/aws_regions_by_service.json b/prowler/providers/aws/aws_regions_by_service.json index 0874d14a07..68e164b13e 100644 --- a/prowler/providers/aws/aws_regions_by_service.json +++ b/prowler/providers/aws/aws_regions_by_service.json @@ -9218,11 +9218,13 @@ "pcs": { "regions": { "aws": [ + "af-south-1", "ap-northeast-1", "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-3", "eu-central-1", "eu-north-1", "eu-south-1", diff --git a/prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/__init__.py b/prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables.metadata.json b/prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables.metadata.json new file mode 100644 index 0000000000..d0866175b9 --- /dev/null +++ b/prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "aws", + "CheckID": "apigateway_restapi_no_secrets_in_stage_variables", + "CheckTitle": "API Gateway REST API stage variables should not contain secrets", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "apigateway", + "SubServiceName": "", + "ResourceIdTemplate": "arn:aws:apigateway:region::/restapis/api-id/stages/stage-name", + "Severity": "high", + "ResourceType": "AwsApiGatewayStage", + "ResourceGroup": "security", + "Description": "Checks API Gateway REST API stage variables for hardcoded secrets such as passwords, API keys, and tokens. Stage variables should reference AWS Secrets Manager or Parameter Store rather than containing plaintext credentials.", + "Risk": "Hardcoded secrets in stage variables are stored in plaintext in the AWS control plane and are visible to anyone with read access to the API Gateway configuration. This can lead to unauthorized access, credential theft, and lateral movement across systems.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/secretsmanager/latest/userguide/integrating_how-services-use-secrets_api-gateway.html" + ], + "Remediation": { + "Code": { + "CLI": "aws apigateway update-stage --rest-api-id --stage-name --patch-operations op=remove,path=/variables/", + "NativeIaC": "", + "Other": "1. Open AWS Console > API Gateway\n2. Select the REST API and stage\n3. Go to Stage Variables tab\n4. Remove any variables containing plaintext secrets\n5. Reference secrets using AWS Secrets Manager integration instead", + "Terraform": "" + }, + "Recommendation": { + "Text": "Remove hardcoded secrets from API Gateway stage variables. Use AWS Secrets Manager or Parameter Store to manage credentials and retrieve them at runtime using Lambda authorizers or integration request mapping templates.", + "Url": "https://hub.prowler.com/check/apigateway_restapi_no_secrets_in_stage_variables" + } + }, + "Categories": [ + "secrets" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Infrastructure Protection" +} diff --git a/prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables.py b/prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables.py new file mode 100644 index 0000000000..490057a5ed --- /dev/null +++ b/prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables.py @@ -0,0 +1,89 @@ +import json + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) +from prowler.providers.aws.services.apigateway.apigateway_client import ( + apigateway_client, +) + + +class apigateway_restapi_no_secrets_in_stage_variables(Check): + """Check that API Gateway REST API stage variables contain no hardcoded secrets.""" + + def execute(self) -> list[Check_Report_AWS]: + findings = [] + secrets_ignore_patterns = apigateway_client.audit_config.get( + "secrets_ignore_patterns", [] + ) + validate = apigateway_client.audit_config.get("secrets_validate", False) + + # Collect one payload per stage (its variables) and scan them all in + # batched Kingfisher invocations instead of one subprocess per stage. + # Findings are keyed by (rest_api index, stage index). + def payloads(): + for api_index, rest_api in enumerate(apigateway_client.rest_apis): + for stage_index, stage in enumerate(rest_api.stages): + if stage.variables: + yield (api_index, stage_index), json.dumps( + stage.variables, indent=2 + ) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), + excluded_secrets=secrets_ignore_patterns, + validate=validate, + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for api_index, rest_api in enumerate(apigateway_client.rest_apis): + for stage_index, stage in enumerate(rest_api.stages): + report = Check_Report_AWS(metadata=self.metadata(), resource=rest_api) + report.resource_arn = stage.arn + report.resource_id = f"{rest_api.name}/{stage.name}" + report.status = "PASS" + report.status_extended = ( + f"No secrets found in stage variables of API Gateway " + f"REST API {rest_api.name} stage {stage.name}." + ) + + if stage.variables: + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan stage variables of API Gateway REST API " + f"{rest_api.name} stage {stage.name} for secrets: " + f"{scan_error}; manual review is required." + ) + findings.append(report) + continue + + detect_secrets_output = batch_results.get((api_index, stage_index)) + if detect_secrets_output: + variable_names = list(stage.variables.keys()) + secrets_string = ", ".join( + [ + f"{secret['type']} in variable " + f"{variable_names[secret['line_number'] - 2]}" + for secret in detect_secrets_output + ] + ) + report.status = "FAIL" + report.status_extended = ( + f"Potential " + f"{'secrets' if len(detect_secrets_output) > 1 else 'secret'} " + f"found in stage variables of API Gateway REST API " + f"{rest_api.name} stage {stage.name} -> {secrets_string}." + ) + annotate_verified_secrets(report, detect_secrets_output) + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/apigateway/apigateway_service.py b/prowler/providers/aws/services/apigateway/apigateway_service.py index 099551f440..94f7d86b8d 100644 --- a/prowler/providers/aws/services/apigateway/apigateway_service.py +++ b/prowler/providers/aws/services/apigateway/apigateway_service.py @@ -179,6 +179,7 @@ class APIGateway(AWSService): tracing_enabled=tracing_enabled, cache_enabled=cache_enabled, cache_data_encrypted=cache_data_encrypted, + variables=stage.get("variables", {}), ) ) except ClientError as error: @@ -265,6 +266,7 @@ class Stage(BaseModel): tracing_enabled: Optional[bool] = None cache_enabled: Optional[bool] = None cache_data_encrypted: Optional[bool] = None + variables: Optional[dict] = {} class PathResourceMethods(BaseModel): diff --git a/prowler/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration.py b/prowler/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration.py index 6595b71085..dbe6fc534c 100644 --- a/prowler/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration.py +++ b/prowler/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration.py @@ -4,7 +4,11 @@ from base64 import b64decode from prowler.config.config import encoding_format_utf_8 from prowler.lib.check.models import Check, Check_Report_AWS from prowler.lib.logger import logger -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.autoscaling.autoscaling_client import ( autoscaling_client, ) @@ -16,13 +20,19 @@ class autoscaling_find_secrets_ec2_launch_configuration(Check): secrets_ignore_patterns = autoscaling_client.audit_config.get( "secrets_ignore_patterns", [] ) - for ( - configuration_arn, - configuration, - ) in autoscaling_client.launch_configurations.items(): - report = Check_Report_AWS(metadata=self.metadata(), resource=configuration) + validate = autoscaling_client.audit_config.get("secrets_validate", False) + configurations = list(autoscaling_client.launch_configurations.values()) - if configuration.user_data: + # Collect the decoded User Data of each launch configuration and scan it + # all in batched Kingfisher invocations instead of one subprocess each. + # Configurations whose User Data cannot be decoded are undecodable (no report), + # matching the original per-resource behavior. + undecodable = set() + + def payloads(): + for index, configuration in enumerate(configurations): + if not configuration.user_data: + continue user_data = b64decode(configuration.user_data) try: if user_data[0:2] == b"\x1f\x8b": # GZIP magic number @@ -35,24 +45,46 @@ class autoscaling_find_secrets_ec2_launch_configuration(Check): logger.warning( f"{configuration.region} -- Unable to decode user data in autoscaling launch configuration {configuration.name}: {error}" ) + undecodable.add(index) continue except Exception as error: logger.error( f"{configuration.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + undecodable.add(index) continue + yield index, user_data - has_secrets = detect_secrets_scan( - data=user_data, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=autoscaling_client.audit_config.get( - "detect_secrets_plugins" - ), + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, configuration in enumerate(configurations): + report = Check_Report_AWS(metadata=self.metadata(), resource=configuration) + + if scan_error and configuration.user_data: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan autoscaling {configuration.name} User Data for " + f"secrets: {scan_error}; manual review is required." ) + findings.append(report) + continue + if index in undecodable: + report.status = "MANUAL" + report.status_extended = f"Could not decode User Data for autoscaling {configuration.name}; manual review is required to scan for secrets." + elif configuration.user_data: + has_secrets = batch_results.get(index) if has_secrets: report.status = "FAIL" report.status_extended = f"Potential secret found in autoscaling {configuration.name} User Data." + annotate_verified_secrets(report, has_secrets) else: report.status = "PASS" report.status_extended = f"No secrets found in autoscaling {configuration.name} User Data." diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code.py b/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code.py index 51c56a2011..d4f3010903 100644 --- a/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code.py +++ b/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code.py @@ -1,65 +1,120 @@ +import fnmatch import os import tempfile +from collections import defaultdict from prowler.lib.check.models import Check, Check_Report_AWS -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.awslambda.awslambda_client import awslambda_client class awslambda_function_no_secrets_in_code(Check): def execute(self): findings = [] - if awslambda_client.functions: - secrets_ignore_patterns = awslambda_client.audit_config.get( - "secrets_ignore_patterns", [] - ) + if not awslambda_client.functions: + return findings + + secrets_ignore_patterns = awslambda_client.audit_config.get( + "secrets_ignore_patterns", [] + ) + # Glob patterns of file names inside the deployment package to skip + # when scanning for secrets (e.g. "*.deps.json" for .NET Lambdas). + secrets_ignore_files = ( + awslambda_client.audit_config.get("secrets_ignore_files", []) or [] + ) + validate = awslambda_client.audit_config.get("secrets_validate", False) + + # Scan files of every function's package in batched + # Kingfisher invocations instead of one subprocess per file per function. + # Each package is extracted one at a time and its files are + # read (byte-faithfully via latin-1) before the extraction is released, + # so only a single package is on disk at a time. Findings are keyed by + # (function index, package-relative file name) so they can be grouped + # back per function. + functions_with_code = [] + + def code_payloads(): for function, function_code in awslambda_client._get_function_code(): - if function_code: - report = Check_Report_AWS( - metadata=self.metadata(), resource=function - ) - - report.status = "PASS" - report.status_extended = ( - f"No secrets found in Lambda function {function.name} code." - ) - with tempfile.TemporaryDirectory() as tmp_dir_name: - function_code.code_zip.extractall(tmp_dir_name) - # List all files - files_in_zip = next(os.walk(tmp_dir_name))[2] - secrets_findings = [] - for file in files_in_zip: - detect_secrets_output = detect_secrets_scan( - file=f"{tmp_dir_name}/{file}", - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=awslambda_client.audit_config.get( - "detect_secrets_plugins", - ), + if not function_code: + continue + index = len(functions_with_code) + functions_with_code.append(function) + with tempfile.TemporaryDirectory() as tmp_dir_name: + function_code.code_zip.extractall(tmp_dir_name) + for root, _, files in os.walk(tmp_dir_name): + for file_name in files: + file_path = os.path.join(root, file_name) + relative_file_path = os.path.relpath( + file_path, tmp_dir_name ) - if detect_secrets_output: - for ( - secret - ) in ( - detect_secrets_output - ): # Appears that only 1 file is being scanned at a time, so could rework this - output_file_name = secret["filename"].replace( - f"{tmp_dir_name}/", "" - ) - secrets_string = ", ".join( - [ - f"{secret['type']} on line {secret['line_number']}" - for secret in detect_secrets_output - ] - ) - secrets_findings.append( - f"{output_file_name}: {secrets_string}" - ) + if any( + fnmatch.fnmatch(relative_file_path, pattern) + for pattern in secrets_ignore_files + ): + continue + try: + with open(file_path, "rb") as code_file: + content = code_file.read().decode("latin-1") + except Exception: + continue + yield (index, relative_file_path), content - if secrets_findings: - final_output_string = "; ".join(secrets_findings) - report.status = "FAIL" - report.status_extended = f"Potential {'secrets' if len(secrets_findings) > 1 else 'secret'} found in Lambda function {function.name} code -> {final_output_string}." + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + code_payloads(), + excluded_secrets=secrets_ignore_patterns, + validate=validate, + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error - findings.append(report) + if scan_error: + # The scan failed before any function's code could be cleared. Report + # MANUAL for every function rather than risk a false PASS. + for function in awslambda_client.functions.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=function) + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan Lambda function {function.name} code for " + f"secrets: {scan_error}; manual review is required." + ) + findings.append(report) + return findings + + findings_by_function = defaultdict(dict) + for (index, file_name), file_findings in batch_results.items(): + findings_by_function[index][file_name] = file_findings + + for index, function in enumerate(functions_with_code): + report = Check_Report_AWS(metadata=self.metadata(), resource=function) + report.status = "PASS" + report.status_extended = ( + f"No secrets found in Lambda function {function.name} code." + ) + + files_with_secrets = findings_by_function.get(index) + if files_with_secrets: + all_secrets = [] + secrets_findings = [] + for file_name, file_findings in files_with_secrets.items(): + all_secrets.extend(file_findings) + secrets_string = ", ".join( + f"{secret['type']} on line {secret['line_number']}" + for secret in file_findings + ) + secrets_findings.append(f"{file_name}: {secrets_string}") + + final_output_string = "; ".join(secrets_findings) + report.status = "FAIL" + report.status_extended = f"Potential {'secrets' if len(secrets_findings) > 1 else 'secret'} found in Lambda function {function.name} code -> {final_output_string}." + annotate_verified_secrets(report, all_secrets) + + findings.append(report) return findings diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables.py b/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables.py index 9448b0239f..065cc7a9b9 100644 --- a/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables.py +++ b/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables.py @@ -1,7 +1,11 @@ import json from prowler.lib.check.models import Check, Check_Report_AWS -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.awslambda.awslambda_client import awslambda_client @@ -11,7 +15,30 @@ class awslambda_function_no_secrets_in_variables(Check): secrets_ignore_patterns = awslambda_client.audit_config.get( "secrets_ignore_patterns", [] ) - for function in awslambda_client.functions.values(): + validate = awslambda_client.audit_config.get("secrets_validate", False) + functions = list(awslambda_client.functions.values()) + + # Scan every function's environment variables in batched Kingfisher + # invocations instead of one subprocess per function. Payloads are + # yielded lazily so only a chunk is held/written at a time, which matters + # for accounts with very large numbers of Lambda functions. + def environment_payloads(): + for index, function in enumerate(functions): + if function.environment: + yield index, json.dumps(function.environment, indent=2) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + environment_payloads(), + excluded_secrets=secrets_ignore_patterns, + validate=validate, + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, function in enumerate(functions): report = Check_Report_AWS(metadata=self.metadata(), resource=function) report.status = "PASS" @@ -20,17 +47,17 @@ class awslambda_function_no_secrets_in_variables(Check): ) if function.environment: - detect_secrets_output = detect_secrets_scan( - data=json.dumps(function.environment, indent=2), - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=awslambda_client.audit_config.get( - "detect_secrets_plugins", - ), - ) - original_env_vars = [] - for name, value in function.environment.items(): - original_env_vars.append(name) + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan Lambda function {function.name} variables " + f"for secrets: {scan_error}; manual review is required." + ) + findings.append(report) + continue + detect_secrets_output = batch_results.get(index) if detect_secrets_output: + original_env_vars = list(function.environment.keys()) secrets_string = ", ".join( [ f"{secret['type']} in variable {original_env_vars[secret['line_number'] - 2]}" @@ -39,6 +66,7 @@ class awslambda_function_no_secrets_in_variables(Check): ) report.status = "FAIL" report.status_extended = f"Potential secret found in Lambda function {function.name} variables -> {secrets_string}." + annotate_verified_secrets(report, detect_secrets_output) findings.append(report) diff --git a/prowler/providers/aws/services/awslambda/awslambda_service.py b/prowler/providers/aws/services/awslambda/awslambda_service.py index 433a5ab588..ac90e9fb11 100644 --- a/prowler/providers/aws/services/awslambda/awslambda_service.py +++ b/prowler/providers/aws/services/awslambda/awslambda_service.py @@ -10,6 +10,10 @@ from botocore.client import ClientError from pydantic.v1 import BaseModel from prowler.lib.logger import logger +from prowler.lib.resource_limit import ( + get_resource_scan_limit, + limit_resources, +) from prowler.lib.scan_filters.scan_filters import is_resource_filtered from prowler.providers.aws.lib.service.service import AWSService @@ -18,8 +22,16 @@ class Lambda(AWSService): def __init__(self, provider): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) + # Functions are listed first, then trimmed to the subset selected for + # analysis before expensive per-function detail is hydrated. self.functions = {} + self.security_groups_in_use = set() + self.regions_with_functions = set() + self.function_limit = get_resource_scan_limit( + self.audit_config, "max_lambda_functions" + ) self.__threading_call__(self._list_functions) + self._select_functions_for_analysis() self._list_tags_for_resource() self.__threading_call__(self._get_policy) self.__threading_call__(self._get_function_url_config) @@ -30,24 +42,29 @@ class Lambda(AWSService): try: list_functions_paginator = regional_client.get_paginator("list_functions") for page in list_functions_paginator.paginate(): - for function in page["Functions"]: - if not self.audit_resources or ( - is_resource_filtered( - function["FunctionArn"], self.audit_resources - ) + for function in page.get("Functions", []): + if not self.audit_resources or is_resource_filtered( + function["FunctionArn"], self.audit_resources ): lambda_name = function["FunctionName"] lambda_arn = function["FunctionArn"] vpc_config = function.get("VpcConfig", {}) + security_groups = vpc_config.get("SecurityGroupIds", []) + self.security_groups_in_use.update(security_groups) + self.regions_with_functions.add(regional_client.region) # We must use the Lambda ARN as the dict key since we could have Lambdas in different regions with the same name self.functions[lambda_arn] = Function( name=lambda_name, arn=lambda_arn, - security_groups=vpc_config.get("SecurityGroupIds", []), + security_groups=security_groups, vpc_id=vpc_config.get("VpcId"), subnet_ids=set(vpc_config.get("SubnetIds", [])), region=regional_client.region, ) + if "LastModified" in function: + self.functions[lambda_arn].last_modified = function[ + "LastModified" + ] if "Runtime" in function: self.functions[lambda_arn].runtime = function["Runtime"] if "Environment" in function: @@ -76,26 +93,61 @@ class Lambda(AWSService): f" {error}" ) + def _select_functions_for_analysis(self): + self.functions = { + function.arn: function + for function in limit_resources( + sorted( + self.functions.values(), + key=lambda f: f.last_modified or "", + reverse=True, + ), + self.function_limit, + ) + } + def _list_event_source_mappings(self, regional_client): logger.info("Lambda - Listing Event Source Mappings...") try: paginator = regional_client.get_paginator("list_event_source_mappings") - for page in paginator.paginate(): - for mapping in page.get("EventSourceMappings", []): - function_arn = mapping.get("FunctionArn", "") - # Normalise to unqualified ARN (strip :qualifier suffix if present) - base_arn = ":".join(function_arn.split(":")[:7]) - if base_arn not in self.functions: - continue - self.functions[base_arn].event_source_mappings.append( - EventSourceMapping( - uuid=mapping["UUID"], - event_source_arn=mapping.get("EventSourceArn", ""), - state=mapping.get("State", ""), - batch_size=mapping.get("BatchSize"), - starting_position=mapping.get("StartingPosition"), + if not self.function_limit: + for page in paginator.paginate(): + self._add_event_source_mappings(page.get("EventSourceMappings", [])) + return + + for function in self.functions.values(): + if function.region != regional_client.region: + continue + try: + for page in paginator.paginate(FunctionName=function.name): + self._add_event_source_mappings( + page.get("EventSourceMappings", []) ) - ) + except ClientError as error: + if ( + error.response.get("Error", {}).get("Code") + == "InvalidParameterValueException" + ): + logger.warning( + f"{function.region} --" + f" {error.__class__.__name__}[{error.__traceback__.tb_lineno}]:" + f" {error}" + ) + else: + logger.error( + f"{function.region} --" + f" {error.__class__.__name__}[{error.__traceback__.tb_lineno}]:" + f" {error}" + ) + raise + except ClientError as error: + if self.function_limit: + raise + logger.error( + f"{regional_client.region} --" + f" {error.__class__.__name__}[{error.__traceback__.tb_lineno}]:" + f" {error}" + ) except Exception as error: logger.error( f"{regional_client.region} --" @@ -103,6 +155,23 @@ class Lambda(AWSService): f" {error}" ) + def _add_event_source_mappings(self, event_source_mappings): + for mapping in event_source_mappings: + function_arn = mapping.get("FunctionArn", "") + # Normalise to unqualified ARN (strip :qualifier suffix if present) + base_arn = ":".join(function_arn.split(":")[:7]) + if base_arn not in self.functions: + continue + self.functions[base_arn].event_source_mappings.append( + EventSourceMapping( + uuid=mapping["UUID"], + event_source_arn=mapping.get("EventSourceArn", ""), + state=mapping.get("State", ""), + batch_size=mapping.get("BatchSize"), + starting_position=mapping.get("StartingPosition"), + ) + ) + def _get_function_code(self): logger.info("Lambda - Getting Function Code...") # Use a thread pool handle the queueing and execution of the _fetch_function_code tasks, up to max_workers tasks concurrently. @@ -158,7 +227,6 @@ class Lambda(AWSService): except ClientError as e: if e.response["Error"]["Code"] == "ResourceNotFoundException": self.functions[function.arn].policy = {} - except Exception as error: logger.error( f"{regional_client.region} --" @@ -187,7 +255,6 @@ class Lambda(AWSService): except ClientError as e: if e.response["Error"]["Code"] == "ResourceNotFoundException": self.functions[function.arn].url_config = None - except Exception as error: logger.error( f"{regional_client.region} --" @@ -206,10 +273,9 @@ class Lambda(AWSService): except ClientError as e: if e.response["Error"]["Code"] == "ResourceNotFoundException": function.tags = [] - except Exception as error: logger.error( - f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"{function.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) @@ -259,6 +325,7 @@ class Function(BaseModel): name: str arn: str security_groups: list + last_modified: Optional[str] = None runtime: Optional[str] = None environment: Optional[dict] = None region: str diff --git a/prowler/providers/aws/services/backup/backup_service.py b/prowler/providers/aws/services/backup/backup_service.py index 4320d42c0b..ddacc9e6d2 100644 --- a/prowler/providers/aws/services/backup/backup_service.py +++ b/prowler/providers/aws/services/backup/backup_service.py @@ -5,6 +5,10 @@ from botocore.client import ClientError from pydantic.v1 import BaseModel from prowler.lib.logger import logger +from prowler.lib.resource_limit import ( + get_resource_scan_limit, + limit_resources, +) from prowler.lib.scan_filters.scan_filters import is_resource_filtered from prowler.providers.aws.lib.service.service import AWSService @@ -27,8 +31,14 @@ class Backup(AWSService): self.__threading_call__(self._list_backup_report_plans) self.protected_resources = [] self.__threading_call__(self._list_backup_selections) + # Recovery points are listed first, then only the selected subset is + # tagged and exposed for checks. self.recovery_points = [] - self.__threading_call__(self._list_recovery_points) + self.recovery_point_limit = get_resource_scan_limit( + self.audit_config, "max_backup_recovery_points" + ) + self.__threading_call__(self._list_recovery_points, self.backup_vaults or []) + self._select_recovery_points_for_analysis() self.__threading_call__(self._list_tags, self.recovery_points) def _list_backup_vaults(self, regional_client): @@ -183,40 +193,63 @@ class Backup(AWSService): f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - def _list_recovery_points(self, regional_client): + def _list_recovery_points(self, backup_vault=None): logger.info("Backup - Listing Recovery Points...") + if backup_vault is None: + for vault in self.backup_vaults or []: + self._list_recovery_points(vault) + return + try: - if self.backup_vaults: - for backup_vault in self.backup_vaults: - paginator = regional_client.get_paginator( - "list_recovery_points_by_backup_vault" - ) - for page in paginator.paginate(BackupVaultName=backup_vault.name): - for recovery_point in page.get("RecoveryPoints", []): - arn = recovery_point.get("RecoveryPointArn") - if arn: - self.recovery_points.append( - RecoveryPoint( - arn=arn, - id=arn.split(":")[-1], - backup_vault_name=backup_vault.name, - encrypted=recovery_point.get( - "IsEncrypted", False - ), - backup_vault_region=backup_vault.region, - region=regional_client.region, - tags=[], - ) - ) + regional_client = self.regional_clients[backup_vault.region] + paginator = regional_client.get_paginator( + "list_recovery_points_by_backup_vault" + ) + for page in paginator.paginate(BackupVaultName=backup_vault.name): + for recovery_point in page.get("RecoveryPoints", []): + arn = recovery_point.get("RecoveryPointArn") + if arn: + rp = RecoveryPoint( + arn=arn, + id=arn.split(":")[-1], + backup_vault_name=backup_vault.name, + encrypted=recovery_point.get("IsEncrypted", False), + creation_date=recovery_point.get("CreationDate"), + backup_vault_region=backup_vault.region, + region=backup_vault.region, + tags=[], + ) + self.recovery_points.append(rp) except ClientError as error: logger.error( - f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"{backup_vault.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}" + f"{backup_vault.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _select_recovery_points_for_analysis(self): + self.recovery_points = list( + limit_resources( + sorted( + self.recovery_points, + key=lambda rp: ( + ( + -rp.creation_date.timestamp() + if isinstance(rp.creation_date, datetime) + else 0.0 + ), + rp.region or "", + rp.backup_vault_name or "", + rp.arn or "", + rp.id or "", + ), + ), + self.recovery_point_limit, + ) + ) + class BackupVault(BaseModel): arn: str @@ -256,4 +289,5 @@ class RecoveryPoint(BaseModel): backup_vault_name: str encrypted: bool backup_vault_region: str + creation_date: Optional[datetime] = None tags: Optional[list] = None diff --git a/prowler/providers/aws/services/bedrock/bedrock_model_invocation_logs_encryption_enabled/bedrock_model_invocation_logs_encryption_enabled.py b/prowler/providers/aws/services/bedrock/bedrock_model_invocation_logs_encryption_enabled/bedrock_model_invocation_logs_encryption_enabled.py index c4adddc344..230cfd2295 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_model_invocation_logs_encryption_enabled/bedrock_model_invocation_logs_encryption_enabled.py +++ b/prowler/providers/aws/services/bedrock/bedrock_model_invocation_logs_encryption_enabled/bedrock_model_invocation_logs_encryption_enabled.py @@ -30,12 +30,13 @@ class bedrock_model_invocation_logs_encryption_enabled(Check): s3_encryption = False if logging.cloudwatch_log_group: log_group_arn = f"arn:{logs_client.audited_partition}:logs:{region}:{logs_client.audited_account}:log-group:{logging.cloudwatch_log_group}" + all_log_groups = getattr(logs_client, "all_log_groups", None) or {} if ( - log_group_arn in logs_client.log_groups - and not logs_client.log_groups[log_group_arn].kms_id + log_group_arn in all_log_groups + and not all_log_groups[log_group_arn].kms_id ) or ( - log_group_arn + ":*" in logs_client.log_groups - and not logs_client.log_groups[log_group_arn + ":*"].kms_id + log_group_arn + ":*" in all_log_groups + and not all_log_groups[log_group_arn + ":*"].kms_id ): cloudwatch_encryption = False if not s3_encryption and not cloudwatch_encryption: diff --git a/prowler/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets.py b/prowler/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets.py index f9b47932bb..b86f851765 100644 --- a/prowler/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets.py +++ b/prowler/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets.py @@ -1,5 +1,9 @@ from prowler.lib.check.models import Check, Check_Report_AWS -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.cloudformation.cloudformation_client import ( cloudformation_client, ) @@ -14,26 +18,41 @@ class cloudformation_stack_outputs_find_secrets(Check): secrets_ignore_patterns = cloudformation_client.audit_config.get( "secrets_ignore_patterns", [] ) - for stack in cloudformation_client.stacks: + validate = cloudformation_client.audit_config.get("secrets_validate", False) + stacks = list(cloudformation_client.stacks) + + # Collect one payload per stack (its Outputs) and scan them all in + # batched Kingfisher invocations instead of one subprocess per stack. + def payloads(): + for index, stack in enumerate(stacks): + if stack.outputs: + yield index, "".join(f"{output}\n" for output in stack.outputs) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, stack in enumerate(stacks): report = Check_Report_AWS(metadata=self.metadata(), resource=stack) report.status = "PASS" report.status_extended = ( f"No secrets found in CloudFormation Stack {stack.name} Outputs." ) if stack.outputs: - data = "" - # Store the CloudFormation Stack Outputs into a file - for output in stack.outputs: - data += f"{output}\n" - - detect_secrets_output = detect_secrets_scan( - data=data, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=cloudformation_client.audit_config.get( - "detect_secrets_plugins", - ), - ) - # If secrets are found, update the report status + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan CloudFormation Stack {stack.name} Outputs " + f"for secrets: {scan_error}; manual review is required." + ) + findings.append(report) + continue + detect_secrets_output = batch_results.get(index) if detect_secrets_output: secrets_string = ", ".join( [ @@ -43,7 +62,7 @@ class cloudformation_stack_outputs_find_secrets(Check): ) report.status = "FAIL" report.status_extended = f"Potential secret found in CloudFormation Stack {stack.name} Outputs -> {secrets_string}." - + annotate_verified_secrets(report, detect_secrets_output) else: report.status = "PASS" report.status_extended = ( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_group_no_secrets_in_logs/cloudwatch_log_group_no_secrets_in_logs.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_group_no_secrets_in_logs/cloudwatch_log_group_no_secrets_in_logs.py index 5154a5acb7..9cfd17ab0a 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_group_no_secrets_in_logs/cloudwatch_log_group_no_secrets_in_logs.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_group_no_secrets_in_logs/cloudwatch_log_group_no_secrets_in_logs.py @@ -1,7 +1,11 @@ from json import dumps, loads from prowler.lib.check.models import Check, Check_Report_AWS -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( convert_to_cloudwatch_timestamp_format, ) @@ -11,95 +15,197 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_group_no_secrets_in_logs(Check): def execute(self): findings = [] - if logs_client.log_groups: - secrets_ignore_patterns = logs_client.audit_config.get( - "secrets_ignore_patterns", [] - ) + if not logs_client.log_groups: + return findings + + secrets_ignore_patterns = logs_client.audit_config.get( + "secrets_ignore_patterns", [] + ) + validate = logs_client.audit_config.get("secrets_validate", False) + + # Phase 1: batch-scan every (log group, log stream). Payloads are yielded + # lazily so only a chunk is written/held at a time, which matters for + # accounts with very large numbers of log groups/streams. The log group + # ARN (not its name) keys every map below, since group and stream names + # are not unique across regions and would otherwise collide. + def stream_payloads(): for log_group in logs_client.log_groups.values(): - report = Check_Report_AWS(metadata=self.metadata(), resource=log_group) - report.status = "PASS" - report.status_extended = ( - f"No secrets found in {log_group.name} log group." + if not log_group.log_streams: + continue + for log_stream_name, events in log_group.log_streams.items(): + yield ( + (log_group.arn, log_stream_name), + "\n".join(dumps(event["message"]) for event in events), + ) + + # A scanner failure here must never look like "no secrets": log groups + # whose streams could not be scanned are reported MANUAL in Phase 4. + stream_scan_error = None + try: + stream_results = detect_secrets_scan_batch( + stream_payloads(), + excluded_secrets=secrets_ignore_patterns, + validate=validate, + ) + except SecretsScanError as error: + stream_results = {} + stream_scan_error = error + + # Phase 2: plan the per-event secrets for each flagged stream and collect + # the multiline events to rescan. Each multiline event is rescanned once + # to resolve per-line detail; the rescans are batched in Phase 3 instead + # of one subprocess per event. The event index (``line_number - 1``, + # since Phase 1 joins one event per line) is the per-event discriminator: + # a CloudWatch stream can hold several events sharing one millisecond + # timestamp, so keying only by timestamp would let a later multiline + # event overwrite an earlier one's payload and lose secret evidence. + # Output still groups/displays by timestamp; only the rescan identity is + # per event. + # stream_plans: (group arn, stream) -> + # {timestamp: {event index: {"multiline", "types"}}} + # rescan_payloads: (group arn, stream, timestamp, event index) -> + # multiline event data + stream_plans = {} + rescan_payloads = {} + groups_with_rescan = set() # group arns that depend on the Phase 3 rescan + for log_group in logs_client.log_groups.values(): + for log_stream_name in log_group.log_streams or {}: + stream_secrets = stream_results.get((log_group.arn, log_stream_name)) + if not stream_secrets: + continue + events = log_group.log_streams[log_stream_name] + plan = {} + for secret in stream_secrets: + event_index = secret["line_number"] - 1 + flagged_event = events[event_index] + cloudwatch_timestamp = convert_to_cloudwatch_timestamp_format( + flagged_event["timestamp"] + ) + try: + log_event_data = dumps( + loads(flagged_event["message"]), indent=2 + ) + except Exception: + log_event_data = dumps(flagged_event["message"], indent=2) + multiline = len(log_event_data.split("\n")) > 1 + events_at_timestamp = plan.setdefault(cloudwatch_timestamp, {}) + if event_index not in events_at_timestamp: + events_at_timestamp[event_index] = { + "multiline": multiline, + "types": [], + } + if multiline: + # More informative output is possible with more than one + # line: the event is rescanned to get the type and line + # number of each secret. + rescan_payloads[ + ( + log_group.arn, + log_stream_name, + cloudwatch_timestamp, + event_index, + ) + ] = log_event_data + groups_with_rescan.add(log_group.arn) + else: + events_at_timestamp[event_index]["types"].append(secret["type"]) + stream_plans[(log_group.arn, log_stream_name)] = plan + + # Phase 3: one batched rescan for all multiline flagged events. Validation + # is never enabled here: this rescan only resolves line numbers for + # display and must not re-authenticate the secret. + # If the rescan fails we know secrets were already found in Phase 1, so + # the affected groups must not silently pass; they are reported MANUAL. + rescan_scan_error = None + rescan_results = {} + if rescan_payloads: + try: + rescan_results = detect_secrets_scan_batch( + rescan_payloads, excluded_secrets=secrets_ignore_patterns ) - log_group_secrets = [] - if log_group.log_streams: - for log_stream_name in log_group.log_streams: - log_stream_secrets = {} - log_stream_data = "\n".join( - [ - dumps(event["message"]) - for event in log_group.log_streams[log_stream_name] - ] - ) - log_stream_secrets_output = detect_secrets_scan( - data=log_stream_data, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=logs_client.audit_config.get( - "detect_secrets_plugins", - ), - ) + except SecretsScanError as error: + rescan_scan_error = error - if log_stream_secrets_output: - for secret in log_stream_secrets_output: - flagged_event = log_group.log_streams[log_stream_name][ - secret["line_number"] - 1 - ] - cloudwatch_timestamp = ( - convert_to_cloudwatch_timestamp_format( - flagged_event["timestamp"] - ) - ) - if ( - cloudwatch_timestamp - not in log_stream_secrets.keys() - ): - log_stream_secrets[cloudwatch_timestamp] = ( - SecretsDict() - ) + # Phase 4: assemble one report per log group. + for log_group in logs_client.log_groups.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=log_group) + report.status = "PASS" + report.status_extended = f"No secrets found in {log_group.name} log group." - try: - log_event_data = dumps( - loads(flagged_event["message"]), indent=2 - ) - except Exception: - log_event_data = dumps( - flagged_event["message"], indent=2 - ) - if len(log_event_data.split("\n")) > 1: - # Can get more informative output if there is more than 1 line. - # Will rescan just this event to get the type of secret and the line number - event_detect_secrets_output = detect_secrets_scan( - data=log_event_data, - detect_secrets_plugins=logs_client.audit_config.get( - "detect_secrets_plugins" - ), - ) - if event_detect_secrets_output: - for secret in event_detect_secrets_output: - log_stream_secrets[ - cloudwatch_timestamp - ].add_secret( - secret["line_number"], secret["type"] - ) - else: - log_stream_secrets[cloudwatch_timestamp].add_secret( - 1, secret["type"] - ) - if log_stream_secrets: - secrets_string = "; ".join( - [ - f"at {timestamp} - {log_stream_secrets[timestamp].to_string()}" - for timestamp in log_stream_secrets - ] - ) - log_group_secrets.append( - f"in log stream {log_stream_name} {secrets_string}" - ) - if log_group_secrets: - secrets_string = "; ".join(log_group_secrets) - report.status = "FAIL" - report.status_extended = f"Potential secrets found in log group {log_group.name} {secrets_string}." + # The stream scan failed: we cannot conclude this group is clean. + if stream_scan_error and log_group.log_streams: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan log group {log_group.name} for secrets: " + f"{stream_scan_error}; manual review is required." + ) findings.append(report) + continue + + log_group_secrets = [] + all_secrets = [] + for log_stream_name in log_group.log_streams or {}: + stream_secrets = stream_results.get((log_group.arn, log_stream_name)) + if not stream_secrets: + continue + all_secrets.extend(stream_secrets) + log_stream_secrets = {} + for cloudwatch_timestamp, events_at_timestamp in stream_plans[ + (log_group.arn, log_stream_name) + ].items(): + secrets_dict = SecretsDict() + # Multiple events can share one timestamp; aggregate each + # event's secrets into the timestamp's display entry. + for event_index, entry in events_at_timestamp.items(): + if entry["multiline"]: + for event_secret in rescan_results.get( + ( + log_group.arn, + log_stream_name, + cloudwatch_timestamp, + event_index, + ), + [], + ): + secrets_dict.add_secret( + event_secret["line_number"], event_secret["type"] + ) + else: + for secret_type in entry["types"]: + secrets_dict.add_secret(1, secret_type) + # Only record the event when at least one non-ignored secret + # remains after the rescan. A multiline event whose secrets + # were all dropped by ``secrets_ignore_patterns`` leaves an + # empty SecretsDict, which must not produce a FAIL with no + # actual secret evidence. + if secrets_dict: + log_stream_secrets[cloudwatch_timestamp] = secrets_dict + if log_stream_secrets: + secrets_string = "; ".join( + [ + f"at {timestamp} - {log_stream_secrets[timestamp].to_string()}" + for timestamp in log_stream_secrets + ] + ) + log_group_secrets.append( + f"in log stream {log_stream_name} {secrets_string}" + ) + # The multiline rescan failed for a group that had flagged secrets: + # detail is unavailable, so report MANUAL rather than risk a false + # PASS when every flagged event was multiline. + if rescan_scan_error and log_group.arn in groups_with_rescan: + report.status = "MANUAL" + report.status_extended = ( + f"Secrets were detected in log group {log_group.name} but the " + f"detailed rescan failed: {rescan_scan_error}; manual review " + "is required." + ) + elif log_group_secrets: + secrets_string = "; ".join(log_group_secrets) + report.status = "FAIL" + report.status_extended = f"Potential secrets found in log group {log_group.name} {secrets_string}." + annotate_verified_secrets(report, all_secrets) + findings.append(report) return findings diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_service.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_service.py index 29ac103867..56b7ebe25c 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_service.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_service.py @@ -6,6 +6,10 @@ from botocore.exceptions import ClientError from pydantic.v1 import BaseModel from prowler.lib.logger import logger +from prowler.lib.resource_limit import ( + get_resource_scan_limit, + limit_resources, +) from prowler.lib.scan_filters.scan_filters import is_resource_filtered from prowler.providers.aws.lib.service.service import AWSService @@ -83,8 +87,19 @@ class Logs(AWSService): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.log_group_arn_template = f"arn:{self.audited_partition}:logs:{self.region}:{self.audited_account}:log-group" + # Log groups are listed first, then only the selected subset is enriched + # and exposed for primary log group checks. Keep a complete lightweight + # index for cross-service evidence lookups. + self.all_log_groups = {} self.log_groups = {} + self._log_groups_hydrated = set() + self.log_group_limit = get_resource_scan_limit( + self.audit_config, "max_cloudwatch_log_groups" + ) + # The threshold for number of events to return per log group. + self.events_per_log_group_threshold = 1000 self.__threading_call__(self._describe_log_groups) + self._select_log_groups_for_analysis() self.resource_policies = {} self.__threading_call__(self._describe_resource_policies) self.metric_filters = [] @@ -94,14 +109,27 @@ class Logs(AWSService): "cloudwatch_log_group_no_secrets_in_logs" in provider.audit_metadata.expected_checks ): - self.events_per_log_group_threshold = ( - 1000 # The threshold for number of events to return per log group. - ) - self.__threading_call__(self._get_log_events) + self.__threading_call__(self._get_log_events, self.log_groups.values()) self.__threading_call__( self._list_tags_for_resource, self.log_groups.values() ) + def _select_log_groups_for_analysis(self): + """Select the newest log groups for bounded analysis.""" + if not self.log_groups: + return + self.log_groups = { + log_group.arn: log_group + for log_group in limit_resources( + sorted( + self.log_groups.values(), + key=lambda lg: lg.creation_time or 0, + reverse=True, + ), + self.log_group_limit, + ) + } + def _describe_metric_filters(self, regional_client): logger.info("CloudWatch Logs - Describing metric filters...") try: @@ -118,11 +146,21 @@ class Logs(AWSService): self.metric_filters = [] log_group = None - for lg in self.log_groups.values(): - if lg.name == filter["logGroupName"]: + for lg in (self.all_log_groups or {}).values(): + if ( + lg.name == filter["logGroupName"] + and lg.region == regional_client.region + ): log_group = lg break + if ( + log_group + and log_group.arn in (self.log_groups or {}) + and log_group.arn not in self._log_groups_hydrated + ): + self._list_tags_for_resource(log_group) + self.metric_filters.append( MetricFilter( arn=arn, @@ -156,9 +194,9 @@ class Logs(AWSService): "describe_log_groups" ) for page in describe_log_groups_paginator.paginate(): - for log_group in page["logGroups"]: - if not self.audit_resources or ( - is_resource_filtered(log_group["arn"], self.audit_resources) + for log_group in page.get("logGroups", []): + if not self.audit_resources or is_resource_filtered( + log_group["arn"], self.audit_resources ): never_expire = False kms = log_group.get("kmsKeyId") @@ -168,20 +206,26 @@ class Logs(AWSService): retention_days = 9999 if self.log_groups is None: self.log_groups = {} - self.log_groups[log_group["arn"]] = LogGroup( + if self.all_log_groups is None: + self.all_log_groups = {} + log_group_object = LogGroup( arn=log_group["arn"], name=log_group["logGroupName"], retention_days=retention_days, never_expire=never_expire, kms_id=kms, + creation_time=log_group.get("creationTime"), region=regional_client.region, ) + self.all_log_groups[log_group_object.arn] = log_group_object + self.log_groups[log_group_object.arn] = log_group_object except ClientError as error: if error.response["Error"]["Code"] == "AccessDeniedException": logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) if not self.log_groups: + self.all_log_groups = None self.log_groups = None else: logger.error( @@ -192,37 +236,29 @@ class Logs(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - def _get_log_events(self, regional_client): - regional_log_groups = [ - log_group - for log_group in self.log_groups.values() - if log_group.region == regional_client.region - ] - total_log_groups = len(regional_log_groups) + def _get_log_events(self, log_group): + """Retrieve recent log events for a selected log group. + + Args: + log_group: Log group selected for bounded analysis. + """ logger.info( - f"CloudWatch Logs - Retrieving log events for {total_log_groups} log groups in {regional_client.region}..." + f"CloudWatch Logs - Retrieving log events for log group {log_group.name}..." ) try: - for count, log_group in enumerate(regional_log_groups, start=1): - events = regional_client.filter_log_events( - logGroupName=log_group.name, - limit=self.events_per_log_group_threshold, - )["events"] - for event in events: - if event["logStreamName"] not in log_group.log_streams: - log_group.log_streams[event["logStreamName"]] = [] - log_group.log_streams[event["logStreamName"]].append(event) - if count % 10 == 0: - logger.info( - f"CloudWatch Logs - Retrieved log events for {count}/{total_log_groups} log groups in {regional_client.region}..." - ) + regional_client = self.regional_clients[log_group.region] + events = regional_client.filter_log_events( + logGroupName=log_group.name, + limit=self.events_per_log_group_threshold, + )["events"] + for event in events: + if event["logStreamName"] not in log_group.log_streams: + log_group.log_streams[event["logStreamName"]] = [] + log_group.log_streams[event["logStreamName"]].append(event) except Exception as error: logger.error( - f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"{log_group.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - logger.info( - f"CloudWatch Logs - Finished retrieving log events in {regional_client.region}..." - ) def _describe_resource_policies(self, regional_client): logger.info("CloudWatch Logs - Describing resource policies...") @@ -257,6 +293,13 @@ class Logs(AWSService): ) def _list_tags_for_resource(self, log_group): + """Hydrate tags for a selected log group once. + + Args: + log_group: Log group selected for tag hydration. + """ + if log_group.arn in self._log_groups_hydrated: + return logger.info(f"CloudWatch Logs - List Tags for Log Group {log_group.name}...") try: regional_client = self.regional_clients[log_group.region] @@ -264,6 +307,7 @@ class Logs(AWSService): resourceArn=log_group.arn )["tags"] log_group.tags = [response] + self._log_groups_hydrated.add(log_group.arn) except ClientError as error: if error.response["Error"]["Code"] == "ResourceNotFoundException": logger.warning( @@ -292,6 +336,7 @@ class LogGroup(BaseModel): retention_days: int never_expire: bool kms_id: Optional[str] + creation_time: Optional[int] = None region: str log_streams: dict[str, list[str]] = ( {} diff --git a/prowler/providers/aws/services/codeartifact/codeartifact_service.py b/prowler/providers/aws/services/codeartifact/codeartifact_service.py index 1465092063..9a13e8fcf7 100644 --- a/prowler/providers/aws/services/codeartifact/codeartifact_service.py +++ b/prowler/providers/aws/services/codeartifact/codeartifact_service.py @@ -1,10 +1,14 @@ from enum import Enum -from typing import Optional +from typing import Iterator, Optional, Tuple from botocore.exceptions import ClientError from pydantic.v1 import BaseModel from prowler.lib.logger import logger +from prowler.lib.resource_limit import ( + get_resource_scan_limit, + iter_limited_paginator_items, +) from prowler.lib.scan_filters.scan_filters import is_resource_filtered from prowler.providers.aws.lib.service.service import AWSService @@ -15,9 +19,18 @@ class CodeArtifact(AWSService): super().__init__(__class__.__name__, provider) # repositories is a dictionary containing all the codeartifact service information self.repositories = {} + # repository ARNs whose selected packages have been listed and memoized + # into repository.packages. + self._packages_listed = set() + self.package_limit = get_resource_scan_limit( + self.audit_config, "max_codeartifact_packages" + ) self.__threading_call__(self._list_repositories) - self.__threading_call__(self._list_packages) - self._list_tags_for_resource() + for _ in self._load_packages_for_analysis(): + pass + self.__threading_call__( + self._list_tags_for_resource, self.repositories.values() + ) def _list_repositories(self, regional_client): logger.info("CodeArtifact - Listing Repositories...") @@ -51,134 +64,146 @@ class CodeArtifact(AWSService): f" {error}" ) - def _list_packages(self, regional_client): - logger.info("CodeArtifact - Listing Packages and retrieving information...") - for repository in self.repositories: - try: - if self.repositories[repository].region == regional_client.region: - list_packages_paginator = regional_client.get_paginator( - "list_packages" + def _iter_repository_packages( + self, repository, limit: Optional[int] = None + ) -> Iterator["Package"]: + """Yield packages for a single repository, hydrating each one lazily. + + Each package requires an extra ``list_package_versions`` call to + resolve its latest version, so producing them lazily lets the resource + limit stop before extra package version calls. + """ + regional_client = self.regional_clients[repository.region] + try: + list_packages_paginator = regional_client.get_paginator("list_packages") + list_packages_parameters = { + "domain": repository.domain_name, + "domainOwner": repository.domain_owner, + "repository": repository.name, + } + for package in iter_limited_paginator_items( + list_packages_paginator, + "packages", + limit, + **list_packages_parameters, + ): + # Package information + package_format = package["format"] + package_namespace = package.get("namespace") + package_name = package["package"] + package_origin_configuration_restrictions_publish = package[ + "originConfiguration" + ]["restrictions"]["publish"] + package_origin_configuration_restrictions_upstream = package[ + "originConfiguration" + ]["restrictions"]["upstream"] + # Get Latest Package Version + list_package_versions_parameters = { + "domain": repository.domain_name, + "domainOwner": repository.domain_owner, + "repository": repository.name, + "format": package_format, + "package": package_name, + "sortBy": "PUBLISHED_TIME", + "maxResults": 1, + } + if package_namespace: + list_package_versions_parameters["namespace"] = package_namespace + latest_version_information = regional_client.list_package_versions( + **list_package_versions_parameters + ) + latest_version = "" + latest_origin_type = "UNKNOWN" + latest_status = "Published" + if latest_version_information.get("versions"): + latest_version = latest_version_information["versions"][0].get( + "version" ) - list_packages_parameters = { - "domain": self.repositories[repository].domain_name, - "domainOwner": self.repositories[repository].domain_owner, - "repository": self.repositories[repository].name, - } - packages = [] - for page in list_packages_paginator.paginate( - **list_packages_parameters - ): - for package in page["packages"]: - # Package information - package_format = package["format"] - package_namespace = package.get("namespace") - package_name = package["package"] - package_origin_configuration_restrictions_publish = package[ - "originConfiguration" - ]["restrictions"]["publish"] - package_origin_configuration_restrictions_upstream = ( - package["originConfiguration"]["restrictions"][ - "upstream" - ] - ) - # Get Latest Package Version - if package_namespace: - latest_version_information = ( - regional_client.list_package_versions( - domain=self.repositories[ - repository - ].domain_name, - domainOwner=self.repositories[ - repository - ].domain_owner, - repository=self.repositories[repository].name, - format=package_format, - namespace=package_namespace, - package=package_name, - sortBy="PUBLISHED_TIME", - maxResults=1, - ) - ) - else: - latest_version_information = ( - regional_client.list_package_versions( - domain=self.repositories[ - repository - ].domain_name, - domainOwner=self.repositories[ - repository - ].domain_owner, - repository=self.repositories[repository].name, - format=package_format, - package=package_name, - sortBy="PUBLISHED_TIME", - maxResults=1, - ) - ) - latest_version = "" - latest_origin_type = "UNKNOWN" - latest_status = "Published" - if latest_version_information.get("versions"): - latest_version = latest_version_information["versions"][ - 0 - ].get("version") - latest_origin_type = ( - latest_version_information["versions"][0] - .get("origin", {}) - .get("originType", "UNKNOWN") - ) - latest_status = latest_version_information["versions"][ - 0 - ].get("status", "Published") - - packages.append( - Package( - name=package_name, - namespace=package_namespace, - format=package_format, - origin_configuration=OriginConfiguration( - restrictions=Restrictions( - publish=package_origin_configuration_restrictions_publish, - upstream=package_origin_configuration_restrictions_upstream, - ) - ), - latest_version=LatestPackageVersion( - version=latest_version, - status=latest_status, - origin=OriginInformation( - origin_type=latest_origin_type - ), - ), - ) - ) - # Save all the packages information - self.repositories[repository].packages = packages - - except ClientError as error: - if error.response["Error"]["Code"] == "ResourceNotFoundException": - logger.warning( - f"{regional_client.region} --" - f" {error.__class__.__name__}[{error.__traceback__.tb_lineno}]:" - f" {error}" + latest_origin_type = ( + latest_version_information["versions"][0] + .get("origin", {}) + .get("originType", "UNKNOWN") + ) + latest_status = latest_version_information["versions"][0].get( + "status", "Published" ) - continue - except Exception as error: - logger.error( - f"{regional_client.region} --" + yield Package( + name=package_name, + namespace=package_namespace, + format=package_format, + origin_configuration=OriginConfiguration( + restrictions=Restrictions( + publish=package_origin_configuration_restrictions_publish, + upstream=package_origin_configuration_restrictions_upstream, + ) + ), + latest_version=LatestPackageVersion( + version=latest_version, + status=latest_status, + origin=OriginInformation(origin_type=latest_origin_type), + ), + ) + + except ClientError as error: + if error.response["Error"]["Code"] == "ResourceNotFoundException": + logger.warning( + f"{repository.region} --" f" {error.__class__.__name__}[{error.__traceback__.tb_lineno}]:" f" {error}" ) + else: + logger.error( + f"{repository.region} --" + f" {error.__class__.__name__}[{error.__traceback__.tb_lineno}]:" + f" {error}" + ) + except Exception as error: + logger.error( + f"{repository.region} --" + f" {error.__class__.__name__}[{error.__traceback__.tb_lineno}]:" + f" {error}" + ) - def _list_tags_for_resource(self): + def _load_packages_for_analysis(self) -> Iterator[Tuple["Repository", "Package"]]: + """Yield the ``(repository, package)`` pairs selected for analysis. + + Package listing stays in the service layer so checks receive only the + selected packages and remain unaware of resource-analysis limits. + """ + yielded = 0 + for repository in list(self.repositories.values()): + if repository.arn in self._packages_listed: + for package in repository.packages: + yield repository, package + yielded += 1 + if self.package_limit and yielded >= self.package_limit: + return + continue + collected = [] + remaining_limit = None + if self.package_limit: + remaining_limit = self.package_limit - yielded + if remaining_limit <= 0: + return + for package in self._iter_repository_packages(repository, remaining_limit): + collected.append(package) + repository.packages = collected + yield repository, package + yielded += 1 + if self.package_limit and yielded >= self.package_limit: + self._packages_listed.add(repository.arn) + return + self._packages_listed.add(repository.arn) + + def _list_tags_for_resource(self, repository): logger.info("CodeArtifact - List Tags...") try: - for repository in self.repositories.values(): - regional_client = self.regional_clients[repository.region] - response = regional_client.list_tags_for_resource( - resourceArn=repository.arn - )["tags"] - repository.tags = response + regional_client = self.regional_clients[repository.region] + response = regional_client.list_tags_for_resource( + resourceArn=repository.arn + )["tags"] + repository.tags = response except Exception as error: logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" diff --git a/prowler/providers/aws/services/codebuild/codebuild_project_no_secrets_in_variables/codebuild_project_no_secrets_in_variables.py b/prowler/providers/aws/services/codebuild/codebuild_project_no_secrets_in_variables/codebuild_project_no_secrets_in_variables.py index 8d031cc25a..2e0c5863eb 100644 --- a/prowler/providers/aws/services/codebuild/codebuild_project_no_secrets_in_variables/codebuild_project_no_secrets_in_variables.py +++ b/prowler/providers/aws/services/codebuild/codebuild_project_no_secrets_in_variables/codebuild_project_no_secrets_in_variables.py @@ -1,7 +1,11 @@ import json from prowler.lib.check.models import Check, Check_Report_AWS -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.codebuild.codebuild_client import codebuild_client @@ -14,35 +18,71 @@ class codebuild_project_no_secrets_in_variables(Check): secrets_ignore_patterns = codebuild_client.audit_config.get( "secrets_ignore_patterns", [] ) - for project in codebuild_client.projects.values(): + validate = codebuild_client.audit_config.get("secrets_validate", False) + projects = list(codebuild_client.projects.values()) + + # Collect every scannable plaintext variable across all projects and scan + # them in batched Kingfisher invocations instead of one subprocess per + # variable. Findings are keyed by (project index, variable index). + def payloads(): + for project_index, project in enumerate(projects): + if project.environment_variables: + for var_index, env_var in enumerate(project.environment_variables): + if ( + env_var.type == "PLAINTEXT" + and env_var.name not in sensitive_vars_excluded + ): + yield (project_index, var_index), json.dumps( + {env_var.name: env_var.value} + ) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for project_index, project in enumerate(projects): report = Check_Report_AWS(metadata=self.metadata(), resource=project) report.status = "PASS" report.status_extended = f"CodeBuild project {project.name} does not have sensitive environment plaintext credentials." secrets_found = [] + all_secrets = [] + + if scan_error and any( + env_var.type == "PLAINTEXT" + and env_var.name not in sensitive_vars_excluded + for env_var in project.environment_variables or [] + ): + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan CodeBuild project {project.name} environment " + f"variables for secrets: {scan_error}; manual review is required." + ) + findings.append(report) + continue if project.environment_variables: - for env_var in project.environment_variables: - if ( - env_var.type == "PLAINTEXT" - and env_var.name not in sensitive_vars_excluded - ): - detect_secrets_output = detect_secrets_scan( - data=json.dumps({env_var.name: env_var.value}), - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=codebuild_client.audit_config.get( - "detect_secrets_plugins", - ), - ) - if detect_secrets_output: - secrets_info = [ + for var_index, env_var in enumerate(project.environment_variables): + detect_secrets_output = batch_results.get( + (project_index, var_index) + ) + if detect_secrets_output: + all_secrets.extend(detect_secrets_output) + secrets_found.extend( + [ f"{secret['type']} in variable {env_var.name}" for secret in detect_secrets_output ] - secrets_found.extend(secrets_info) + ) if secrets_found: report.status = "FAIL" report.status_extended = f"CodeBuild project {project.name} has sensitive environment plaintext credentials in variables: {', '.join(secrets_found)}." + annotate_verified_secrets(report, all_secrets) findings.append(report) diff --git a/prowler/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data.py b/prowler/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data.py index 3c3864c479..31b3f8fb05 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data.py +++ b/prowler/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data.py @@ -4,7 +4,11 @@ from base64 import b64decode from prowler.config.config import encoding_format_utf_8 from prowler.lib.check.models import Check, Check_Report_AWS from prowler.lib.logger import logger -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.ec2.ec2_client import ec2_client @@ -14,54 +18,86 @@ class ec2_instance_secrets_user_data(Check): secrets_ignore_patterns = ec2_client.audit_config.get( "secrets_ignore_patterns", [] ) - for instance in ec2_client.instances: - if instance.state != "terminated": - report = Check_Report_AWS(metadata=self.metadata(), resource=instance) - if instance.user_data: - user_data = b64decode(instance.user_data) - try: - if user_data[0:2] == b"\x1f\x8b": # GZIP magic number - user_data = zlib.decompress( - user_data, zlib.MAX_WBITS | 32 - ).decode(encoding_format_utf_8) - else: - user_data = user_data.decode(encoding_format_utf_8) - except UnicodeDecodeError as error: - logger.warning( - f"{instance.region} -- Unable to decode user data in EC2 instance {instance.id}: {error}" - ) - continue - except Exception as error: - logger.error( - f"{instance.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - continue - detect_secrets_output = detect_secrets_scan( - data=user_data, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=ec2_client.audit_config.get( - "detect_secrets_plugins" - ), - ) - if detect_secrets_output: - secrets_string = ", ".join( - [ - f"{secret['type']} on line {secret['line_number']}" - for secret in detect_secrets_output - ] - ) - report.status = "FAIL" - report.status_extended = f"Potential secret found in EC2 instance {instance.id} User Data -> {secrets_string}." + validate = ec2_client.audit_config.get("secrets_validate", False) + instances = list(ec2_client.instances) + # Collect the decoded User Data of each non-terminated instance and scan + # it all in batched Kingfisher invocations instead of one subprocess each. + # Instances whose User Data cannot be decoded are undecodable (no report), + # matching the original per-resource behavior. + undecodable = set() + + def payloads(): + for index, instance in enumerate(instances): + if instance.state == "terminated" or not instance.user_data: + continue + user_data = b64decode(instance.user_data) + try: + if user_data[0:2] == b"\x1f\x8b": # GZIP magic number + user_data = zlib.decompress( + user_data, zlib.MAX_WBITS | 32 + ).decode(encoding_format_utf_8) else: - report.status = "PASS" - report.status_extended = ( - f"No secrets found in EC2 instance {instance.id} User Data." - ) + user_data = user_data.decode(encoding_format_utf_8) + except UnicodeDecodeError as error: + logger.warning( + f"{instance.region} -- Unable to decode user data in EC2 instance {instance.id}: {error}" + ) + undecodable.add(index) + continue + except Exception as error: + logger.error( + f"{instance.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + undecodable.add(index) + continue + yield index, user_data + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, instance in enumerate(instances): + if instance.state == "terminated": + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=instance) + if scan_error and instance.user_data: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan EC2 instance {instance.id} User Data for " + f"secrets: {scan_error}; manual review is required." + ) + findings.append(report) + continue + if index in undecodable: + report.status = "MANUAL" + report.status_extended = f"Could not decode User Data for EC2 instance {instance.id}; manual review is required to scan for secrets." + elif instance.user_data: + detect_secrets_output = batch_results.get(index) + if detect_secrets_output: + secrets_string = ", ".join( + [ + f"{secret['type']} on line {secret['line_number']}" + for secret in detect_secrets_output + ] + ) + report.status = "FAIL" + report.status_extended = f"Potential secret found in EC2 instance {instance.id} User Data -> {secrets_string}." + annotate_verified_secrets(report, detect_secrets_output) else: report.status = "PASS" - report.status_extended = f"No secrets found in EC2 instance {instance.id} since User Data is empty." + report.status_extended = ( + f"No secrets found in EC2 instance {instance.id} User Data." + ) + else: + report.status = "PASS" + report.status_extended = f"No secrets found in EC2 instance {instance.id} since User Data is empty." - findings.append(report) + findings.append(report) return findings diff --git a/prowler/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets.py b/prowler/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets.py index 823553bcdf..e84da724a2 100644 --- a/prowler/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets.py +++ b/prowler/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets.py @@ -4,7 +4,11 @@ from base64 import b64decode from prowler.config.config import encoding_format_utf_8 from prowler.lib.check.models import Check, Check_Report_AWS from prowler.lib.logger import logger -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.ec2.ec2_client import ec2_client @@ -14,43 +18,77 @@ class ec2_launch_template_no_secrets(Check): secrets_ignore_patterns = ec2_client.audit_config.get( "secrets_ignore_patterns", [] ) - for template in ec2_client.launch_templates: + validate = ec2_client.audit_config.get("secrets_validate", False) + templates = list(ec2_client.launch_templates) + + # Track versions whose User Data cannot be decoded so the template is + # surfaced (MANUAL) instead of silently claiming no secrets were found. + undecodable_versions = {} + + # Collect the decoded User Data of every (template, version) and scan it + # all in batched Kingfisher invocations instead of one subprocess per + # version. Versions whose User Data cannot be decoded are recorded above. + def payloads(): + for template_index, template in enumerate(templates): + for version_index, version in enumerate(template.versions): + if not version.template_data.user_data: + continue + user_data = b64decode(version.template_data.user_data) + try: + if user_data[0:2] == b"\x1f\x8b": # GZIP magic number + user_data = zlib.decompress( + user_data, zlib.MAX_WBITS | 32 + ).decode(encoding_format_utf_8) + else: + user_data = user_data.decode(encoding_format_utf_8) + except UnicodeDecodeError as error: + logger.warning( + f"{template.region} -- Unable to decode User Data in EC2 Launch Template {template.name} version {version.version_number}: {error}" + ) + undecodable_versions.setdefault(template_index, []).append( + version.version_number + ) + continue + except Exception as error: + logger.error( + f"{template.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + undecodable_versions.setdefault(template_index, []).append( + version.version_number + ) + continue + yield (template_index, version_index), user_data + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for template_index, template in enumerate(templates): report = Check_Report_AWS(metadata=self.metadata(), resource=template) - versions_with_secrets = [] - - for version in template.versions: - if not version.template_data.user_data: - continue - user_data = b64decode(version.template_data.user_data) - - try: - if user_data[0:2] == b"\x1f\x8b": # GZIP magic number - user_data = zlib.decompress( - user_data, zlib.MAX_WBITS | 32 - ).decode(encoding_format_utf_8) - else: - user_data = user_data.decode(encoding_format_utf_8) - except UnicodeDecodeError as error: - logger.warning( - f"{template.region} -- Unable to decode User Data in EC2 Launch Template {template.name} version {version.version_number}: {error}" - ) - continue - except Exception as error: - logger.error( - f"{template.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - continue - - version_secrets = detect_secrets_scan( - data=user_data, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=ec2_client.audit_config.get( - "detect_secrets_plugins" - ), + if scan_error and any( + version.template_data.user_data for version in template.versions + ): + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan EC2 Launch Template {template.name} User Data " + f"for secrets: {scan_error}; manual review is required." ) + findings.append(report) + continue + versions_with_secrets = [] + all_secrets = [] + + for version_index, version in enumerate(template.versions): + version_secrets = batch_results.get((template_index, version_index)) if version_secrets: + all_secrets.extend(version_secrets) secrets_string = ", ".join( [ f"{secret['type']} on line {secret['line_number']}" @@ -61,9 +99,14 @@ class ec2_launch_template_no_secrets(Check): f"Version {version.version_number}: {secrets_string}" ) + undecodable = undecodable_versions.get(template_index, []) if len(versions_with_secrets) > 0: report.status = "FAIL" report.status_extended = f"Potential secret found in User Data for EC2 Launch Template {template.name} in template versions: {', '.join(versions_with_secrets)}." + annotate_verified_secrets(report, all_secrets) + elif undecodable: + report.status = "MANUAL" + report.status_extended = f"Could not decode User Data for EC2 Launch Template {template.name} versions: {', '.join(str(version_number) for version_number in undecodable)}; manual review is required to scan for secrets." else: report.status = "PASS" report.status_extended = f"No secrets found in User Data of any version for EC2 Launch Template {template.name}." diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used.py b/prowler/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used.py index aa621abdfb..c45693c498 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used.py +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used.py @@ -15,11 +15,10 @@ class ec2_securitygroup_not_used(Check): report.resource_details = security_group.name report.status = "PASS" report.status_extended = f"Security group {security_group.name} ({security_group.id}) it is being used." - sg_in_lambda = False + sg_in_lambda = ( + security_group.id in awslambda_client.security_groups_in_use + ) sg_associated = False - for function in awslambda_client.functions.values(): - if security_group.id in function.security_groups: - sg_in_lambda = True for sg in ec2_client.security_groups.values(): if security_group.id in sg.associated_sgs: sg_associated = True diff --git a/prowler/providers/aws/services/ec2/ec2_service.py b/prowler/providers/aws/services/ec2/ec2_service.py index ccdb9e58fe..45a45e10d5 100644 --- a/prowler/providers/aws/services/ec2/ec2_service.py +++ b/prowler/providers/aws/services/ec2/ec2_service.py @@ -6,6 +6,10 @@ from botocore.client import ClientError from pydantic.v1 import BaseModel from prowler.lib.logger import logger +from prowler.lib.resource_limit import ( + get_resource_scan_limit, + limit_resources, +) from prowler.lib.scan_filters.scan_filters import is_resource_filtered from prowler.providers.aws.lib.service.service import AWSService @@ -26,8 +30,12 @@ class EC2(AWSService): self.snapshots = [] self.volumes_with_snapshots = {} self.regions_with_snapshots = {} + # Snapshots are listed first, then limited after per-region snapshot + # presence is derived and before public status is hydrated. + self.snapshot_limit = get_resource_scan_limit( + self.audit_config, "max_ebs_snapshots" + ) self.__threading_call__(self._describe_snapshots) - self.__threading_call__(self._determine_public_snapshots, self.snapshots) self.network_interfaces = {} self.__threading_call__(self._describe_network_interfaces) self.images = [] @@ -36,6 +44,8 @@ class EC2(AWSService): self.__threading_call__(self._describe_volumes) self.attributes_for_regions = {} self.__threading_call__(self._get_resources_for_regions) + self._select_snapshots_for_analysis() + self.__threading_call__(self._determine_public_snapshots, self.snapshots) self.ebs_encryption_by_default = [] self.__threading_call__(self._get_ebs_encryption_settings) self.elastic_ips = [] @@ -207,6 +217,7 @@ class EC2(AWSService): arn=arn, region=regional_client.region, encrypted=snapshot.get("Encrypted", False), + start_time=snapshot.get("StartTime"), tags=snapshot.get("Tags"), volume=snapshot["VolumeId"], ) @@ -243,6 +254,18 @@ class EC2(AWSService): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _select_snapshots_for_analysis(self): + self.snapshots = list( + limit_resources( + sorted( + self.snapshots, + key=lambda s: (s.start_time.timestamp() if s.start_time else 0.0), + reverse=True, + ), + self.snapshot_limit, + ) + ) + def _describe_network_interfaces(self, regional_client): try: # Get Network Interfaces with Public IPs @@ -686,6 +709,7 @@ class Snapshot(BaseModel): region: str encrypted: bool public: bool = False + start_time: Optional[datetime] = None tags: Optional[list] = [] volume: Optional[str] diff --git a/prowler/providers/aws/services/ecs/ecs_service.py b/prowler/providers/aws/services/ecs/ecs_service.py index 560125bf58..e14197162f 100644 --- a/prowler/providers/aws/services/ecs/ecs_service.py +++ b/prowler/providers/aws/services/ecs/ecs_service.py @@ -1,9 +1,16 @@ +from datetime import datetime +from itertools import zip_longest from re import sub from typing import Optional from pydantic.v1 import BaseModel from prowler.lib.logger import logger +from prowler.lib.resource_limit import ( + get_resource_scan_limit, + iter_limited_paginator_items, + limit_resources, +) from prowler.lib.scan_filters.scan_filters import is_resource_filtered from prowler.providers.aws.lib.service.service import AWSService @@ -12,40 +19,95 @@ class ECS(AWSService): def __init__(self, provider): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) + # Task definition ARNs are listed first, then only the selected subset + # is described and exposed for checks. self.task_definitions = {} + self._task_definition_arns = None + self._task_definition_arns_by_region = {} + self.task_definition_limit = get_resource_scan_limit( + self.audit_config, "max_ecs_task_definitions" + ) self.services = {} self.clusters = {} self.task_sets = {} - self.__threading_call__(self._list_task_definitions) - self.__threading_call__( - self._describe_task_definition, self.task_definitions.values() - ) + for _ in self._load_task_definitions_for_analysis(): + pass self.__threading_call__(self._list_clusters) self.__threading_call__(self._describe_clusters, self.clusters.values()) self.__threading_call__(self._describe_services, self.clusters.values()) - def _list_task_definitions(self, regional_client): + def _list_task_definition_arns(self) -> list: + """List task definition ARNs newest-first, memoized. + + AWS returns ``list_task_definitions(sort=DESC)`` results per region. + Prowler limits the task definitions it describes and exposes to checks. + """ + if self._task_definition_arns is not None: + return self._task_definition_arns logger.info("ECS - Listing Task Definitions...") + self.__threading_call__(self._list_task_definition_arns_by_region) + arns_by_region = [] + for region in self.regional_clients: + arns_by_region.append(self._task_definition_arns_by_region.get(region, [])) + arns = [] + for task_definition_batch in zip_longest(*arns_by_region): + for task_definition in task_definition_batch: + if task_definition: + arns.append(task_definition) + self._task_definition_arns = arns + return arns + + def _list_task_definition_arns_by_region(self, regional_client): try: list_ecs_paginator = regional_client.get_paginator("list_task_definitions") - for page in list_ecs_paginator.paginate(): - for task_definition in page["taskDefinitionArns"]: - if not self.audit_resources or ( - is_resource_filtered(task_definition, self.audit_resources) - ): - self.task_definitions[task_definition] = TaskDefinition( - # we want the family name without the revision - name=sub(":.*", "", task_definition.split("/")[-1]), - arn=task_definition, - revision=task_definition.split(":")[-1], - region=regional_client.region, - environment_variables=[], - ) + regional_arns = [] + for task_definition in iter_limited_paginator_items( + list_ecs_paginator, + "taskDefinitionArns", + None, + item_filter=lambda task_definition: not self.audit_resources + or is_resource_filtered(task_definition, self.audit_resources), + sort="DESC", + ): + regional_arns.append((task_definition, regional_client.region)) + self._task_definition_arns_by_region[regional_client.region] = regional_arns except Exception as error: logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _load_task_definitions_for_analysis(self): + """Yield task definitions lazily, describing each one on demand. + + Resources already fetched are memoized in ``self.task_definitions`` and + reused across checks (checks run sequentially, so no locking is needed). + The configured resource limit bounds ``describe_task_definition`` calls. + """ + task_definitions = [] + for arn, region in limit_resources( + self._list_task_definition_arns(), self.task_definition_limit + ): + task_definition = self.task_definitions.get(arn) + if task_definition is None: + task_definition = TaskDefinition( + # we want the family name without the revision + name=sub(":.*", "", arn.split("/")[-1]), + arn=arn, + revision=arn.split(":")[-1], + region=region, + environment_variables=[], + ) + self.task_definitions[arn] = task_definition + task_definitions.append(task_definition) + + self.__threading_call__(self._describe_task_definition, task_definitions) + + for arn, _ in limit_resources( + self._list_task_definition_arns(), self.task_definition_limit + ): + task_definition = self.task_definitions[arn] + yield task_definition + def _describe_task_definition(self, task_definition): logger.info("ECS - Describing Task Definition...") try: @@ -84,6 +146,9 @@ class ECS(AWSService): ) ) task_definition.pid_mode = response["taskDefinition"].get("pidMode", "") + task_definition.registered_at = response["taskDefinition"].get( + "registeredAt" + ) task_definition.tags = response.get("tags") task_definition.network_mode = response["taskDefinition"].get( "networkMode", "bridge" @@ -208,6 +273,7 @@ class TaskDefinition(BaseModel): region: str container_definitions: list[ContainerDefinition] = [] pid_mode: Optional[str] + registered_at: Optional[datetime] = None tags: Optional[list] = [] network_mode: Optional[str] diff --git a/prowler/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets.py b/prowler/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets.py index cd835d0149..b0bc7198ee 100644 --- a/prowler/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets.py +++ b/prowler/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets.py @@ -1,7 +1,11 @@ from json import dumps from prowler.lib.check.models import Check, Check_Report_AWS -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.ecs.ecs_client import ecs_client @@ -11,33 +15,67 @@ class ecs_task_definitions_no_environment_secrets(Check): secrets_ignore_patterns = ecs_client.audit_config.get( "secrets_ignore_patterns", [] ) - for task_definition in ecs_client.task_definitions.values(): + validate = ecs_client.audit_config.get("secrets_validate", False) + task_definitions = list(ecs_client.task_definitions.values()) + + # Scan every (task definition, container) environment in batched + # Kingfisher invocations instead of one subprocess per container. + # Payloads are yielded lazily so only a chunk is held/written at a time. + def environment_payloads(): + for td_index, task_definition in enumerate(task_definitions): + for c_index, container in enumerate( + task_definition.container_definitions + ): + if container.environment: + dump_env_vars = { + env_var.name: env_var.value + for env_var in container.environment + } + yield (td_index, c_index), dumps(dump_env_vars, indent=2) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + environment_payloads(), + excluded_secrets=secrets_ignore_patterns, + validate=validate, + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for td_index, task_definition in enumerate(task_definitions): report = Check_Report_AWS( metadata=self.metadata(), resource=task_definition ) report.resource_id = f"{task_definition.name}:{task_definition.revision}" report.status = "PASS" extended_status_parts = [] + all_secrets = [] - for container in task_definition.container_definitions: + if scan_error and any( + container.environment + for container in task_definition.container_definitions + ): + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan ECS task definition {task_definition.name} with " + f"revision {task_definition.revision} for secrets: {scan_error}; " + "manual review is required." + ) + findings.append(report) + continue + + for c_index, container in enumerate(task_definition.container_definitions): container_secrets_found = [] if container.environment: - dump_env_vars = {} - original_env_vars = [] - for env_var in container.environment: - dump_env_vars.update({env_var.name: env_var.value}) - original_env_vars.append(env_var.name) - - env_data = dumps(dump_env_vars, indent=2) - detect_secrets_output = detect_secrets_scan( - data=env_data, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=ecs_client.audit_config.get( - "detect_secrets_plugins", - ), - ) + original_env_vars = [ + env_var.name for env_var in container.environment + ] + detect_secrets_output = batch_results.get((td_index, c_index)) if detect_secrets_output: + all_secrets.extend(detect_secrets_output) secrets_string = ", ".join( [ f"{secret['type']} on the environment variable {original_env_vars[secret['line_number'] - 2]}" @@ -56,6 +94,7 @@ class ecs_task_definitions_no_environment_secrets(Check): + "; ".join(extended_status_parts) + "." ) + annotate_verified_secrets(report, all_secrets) else: report.status_extended = f"No secrets found in variables of ECS task definition {task_definition.name} with revision {task_definition.revision}." findings.append(report) diff --git a/prowler/providers/aws/services/glue/glue_etl_jobs_no_secrets_in_arguments/glue_etl_jobs_no_secrets_in_arguments.py b/prowler/providers/aws/services/glue/glue_etl_jobs_no_secrets_in_arguments/glue_etl_jobs_no_secrets_in_arguments.py index 50c92f8619..fec480efb1 100644 --- a/prowler/providers/aws/services/glue/glue_etl_jobs_no_secrets_in_arguments/glue_etl_jobs_no_secrets_in_arguments.py +++ b/prowler/providers/aws/services/glue/glue_etl_jobs_no_secrets_in_arguments/glue_etl_jobs_no_secrets_in_arguments.py @@ -1,52 +1,83 @@ -import json - -from prowler.lib.check.models import Check, Check_Report_AWS -from prowler.lib.utils.utils import detect_secrets_scan -from prowler.providers.aws.services.glue.glue_client import glue_client - - -class glue_etl_jobs_no_secrets_in_arguments(Check): - """Check if Glue ETL jobs have secrets in their default arguments. - - Scans the DefaultArguments of each Glue job for hardcoded credentials, - tokens, passwords, and other sensitive values that should be stored in - Secrets Manager or Parameter Store instead. - """ - - def execute(self): - findings = [] - secrets_ignore_patterns = glue_client.audit_config.get( - "secrets_ignore_patterns", [] - ) - for job in glue_client.jobs: - report = Check_Report_AWS(metadata=self.metadata(), resource=job) - report.status = "PASS" - report.status_extended = ( - f"No secrets found in Glue job {job.name} default arguments." - ) - - if job.arguments: - secrets_found = [] - for arg_name, arg_value in job.arguments.items(): - detect_secrets_output = detect_secrets_scan( - data=json.dumps({arg_name: arg_value}), - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=glue_client.audit_config.get( - "detect_secrets_plugins", - ), - ) - if detect_secrets_output: - secrets_found.extend( - [ - f"{secret['type']} in argument {arg_name}" - for secret in detect_secrets_output - ] - ) - - if secrets_found: - report.status = "FAIL" - report.status_extended = f"Potential secrets found in Glue job {job.name} default arguments: {', '.join(secrets_found)}." - - findings.append(report) - - return findings +import json + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) +from prowler.providers.aws.services.glue.glue_client import glue_client + + +class glue_etl_jobs_no_secrets_in_arguments(Check): + """Check if Glue ETL jobs have secrets in their default arguments. + + Scans the DefaultArguments of each Glue job for hardcoded credentials, + tokens, passwords, and other sensitive values that should be stored in + Secrets Manager or Parameter Store instead. + """ + + def execute(self): + findings = [] + secrets_ignore_patterns = glue_client.audit_config.get( + "secrets_ignore_patterns", [] + ) + validate = glue_client.audit_config.get("secrets_validate", False) + jobs = list(glue_client.jobs) + + # Collect every default argument across all jobs and scan them in batched + # Kingfisher invocations instead of one subprocess per argument. Findings + # are keyed by (job index, argument name). + def payloads(): + for job_index, job in enumerate(jobs): + if job.arguments: + for arg_name, arg_value in job.arguments.items(): + yield (job_index, arg_name), json.dumps({arg_name: arg_value}) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for job_index, job in enumerate(jobs): + report = Check_Report_AWS(metadata=self.metadata(), resource=job) + report.status = "PASS" + report.status_extended = ( + f"No secrets found in Glue job {job.name} default arguments." + ) + + if job.arguments and scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan Glue job {job.name} default arguments for " + f"secrets: {scan_error}; manual review is required." + ) + findings.append(report) + continue + + if job.arguments: + secrets_found = [] + all_secrets = [] + for arg_name in job.arguments: + detect_secrets_output = batch_results.get((job_index, arg_name)) + if detect_secrets_output: + all_secrets.extend(detect_secrets_output) + secrets_found.extend( + [ + f"{secret['type']} in argument {arg_name}" + for secret in detect_secrets_output + ] + ) + + if secrets_found: + report.status = "FAIL" + report.status_extended = f"Potential secrets found in Glue job {job.name} default arguments: {', '.join(secrets_found)}." + annotate_verified_secrets(report, all_secrets) + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/iam/lib/privilege_escalation.py b/prowler/providers/aws/services/iam/lib/privilege_escalation.py index 9fded2d5c9..d7f16895d5 100644 --- a/prowler/providers/aws/services/iam/lib/privilege_escalation.py +++ b/prowler/providers/aws/services/iam/lib/privilege_escalation.py @@ -19,6 +19,7 @@ from prowler.providers.aws.services.iam.lib.policy import get_effective_actions # - https://github.com/RhinoSecurityLabs/Security-Research/blob/master/tools/aws-pentest-tools/aws_escalate.py # - https://rhinosecuritylabs.com/aws/aws-privilege-escalation-methods-mitigation/ # - https://github.com/DataDog/pathfinding.cloud (AWS IAM Privilege Escalation Path Library) +# - https://www.beyondtrust.com/blog/entry/aws-agentcore-privilege-escalation (AWS Bedrock AgentCore) privilege_escalation_policies_combination = { # IAM self-escalation and policy manipulation @@ -299,6 +300,7 @@ privilege_escalation_policies_combination = { "PassRole+AgentCoreCreateInterpreter+InvokeInterpreter": { "iam:PassRole", "bedrock-agentcore:CreateCodeInterpreter", + "bedrock-agentcore:StartCodeInterpreterSession", "bedrock-agentcore:InvokeCodeInterpreter", }, # Prerequisite: Existing Bedrock code interpreter with admin role @@ -306,6 +308,40 @@ privilege_escalation_policies_combination = { "bedrock-agentcore:StartCodeInterpreterSession", "bedrock-agentcore:InvokeCodeInterpreter", }, + # Prerequisite: Existing AgentCore Runtime or Harness with admin execution role. + # InvokeAgentRuntimeCommand runs shell commands as root inside the microVM and + # reads the execution role credentials from MMDS, bypassing the agent and guardrails. + "AgentCoreInvokeRuntimeCommand": { + "bedrock-agentcore:InvokeAgentRuntimeCommand", + }, + "PassRole+AgentCoreCreateRuntime+InvokeRuntimeCommand": { + "iam:PassRole", + "bedrock-agentcore:CreateAgentRuntime", + "bedrock-agentcore:CreateAgentRuntimeEndpoint", + "bedrock-agentcore:CreateWorkloadIdentity", + "bedrock-agentcore:InvokeAgentRuntimeCommand", + }, + "PassRole+AgentCoreCreateHarness+InvokeRuntimeCommand": { + "iam:PassRole", + "bedrock-agentcore:CreateHarness", + "bedrock-agentcore:CreateAgentRuntime", + "bedrock-agentcore:CreateAgentRuntimeEndpoint", + "bedrock-agentcore:CreateWorkloadIdentity", + "bedrock-agentcore:GetAgentRuntime", + "bedrock-agentcore:InvokeAgentRuntimeCommand", + }, + # Prerequisite: Existing AgentCore Custom Browser with admin execution role. + # A remote CDP driver on the browser session reads the role credentials from MMDS. + "AgentCoreBrowserSessionConnect": { + "bedrock-agentcore:StartBrowserSession", + "bedrock-agentcore:ConnectBrowserAutomationStream", + }, + "PassRole+AgentCoreCreateBrowser+ConnectBrowser": { + "iam:PassRole", + "bedrock-agentcore:CreateBrowser", + "bedrock-agentcore:StartBrowserSession", + "bedrock-agentcore:ConnectBrowserAutomationStream", + }, # TO-DO: We have to handle AssumeRole just if the resource is * and without conditions # "sts:AssumeRole": {"sts:AssumeRole"}, } diff --git a/prowler/providers/aws/services/inspector2/inspector2_is_enabled/inspector2_is_enabled.py b/prowler/providers/aws/services/inspector2/inspector2_is_enabled/inspector2_is_enabled.py index a9f5efbedd..fd414badfa 100644 --- a/prowler/providers/aws/services/inspector2/inspector2_is_enabled/inspector2_is_enabled.py +++ b/prowler/providers/aws/services/inspector2/inspector2_is_enabled/inspector2_is_enabled.py @@ -15,11 +15,10 @@ class inspector2_is_enabled(Check): if inspector.status == "ENABLED": report.status = "PASS" report.status_extended = "Inspector2 is enabled for EC2 instances, ECR container images, Lambda functions and code." - funtions_in_region = False + functions_in_region = ( + inspector.region in awslambda_client.regions_with_functions + ) ec2_in_region = False - for function in awslambda_client.functions.values(): - if function.region == inspector.region: - funtions_in_region = True for instance in ec2_client.instances: if instance == inspector.region: ec2_in_region = True @@ -36,12 +35,12 @@ class inspector2_is_enabled(Check): failed_services.append("ECR") if inspector.lambda_status != "ENABLED" and ( inspector2_client.provider.scan_unused_services - or funtions_in_region + or functions_in_region ): failed_services.append("Lambda") if inspector.lambda_code_status != "ENABLED" and ( inspector2_client.provider.scan_unused_services - or funtions_in_region + or functions_in_region ): failed_services.append("Lambda Code") diff --git a/prowler/providers/aws/services/s3/s3_bucket_object_public/__init__.py b/prowler/providers/aws/services/s3/s3_bucket_object_public/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/s3/s3_bucket_object_public/s3_bucket_object_public.metadata.json b/prowler/providers/aws/services/s3/s3_bucket_object_public/s3_bucket_object_public.metadata.json new file mode 100644 index 0000000000..197a5aa205 --- /dev/null +++ b/prowler/providers/aws/services/s3/s3_bucket_object_public/s3_bucket_object_public.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "aws", + "CheckID": "s3_bucket_object_public", + "CheckTitle": "Spot-check S3 bucket objects for public ACLs", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Effects/Data Exposure" + ], + "ServiceName": "s3", + "SubServiceName": "", + "ResourceIdTemplate": "arn:partition:s3:::resource", + "Severity": "low", + "ResourceType": "AwsS3Bucket", + "Description": "Spot-checks a configurable sample of objects in each S3 bucket and flags any whose ACL grants access to the AllUsers or AuthenticatedUsers groups. This is a sampling-based check, not a comprehensive audit, so public objects outside the sample can be missed. It is disabled by default and must be enabled via the s3_bucket_object_public_enabled configuration flag.", + "Risk": "Public objects can be accessed by anyone on the internet, potentially leaking sensitive data. A bucket can appear private at the bucket-policy level while still containing individual objects with public ACL grants.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "aws s3api put-object-acl --bucket --key --acl private", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "For complete coverage, enable the s3_bucket_acl_prohibited check, which enforces the BucketOwnerEnforced Object Ownership setting (AWS's recommended approach since April 2023) and prevents public object ACLs entirely. Use this spot-check as a supplementary tool for manual assessments.", + "Url": "https://hub.prowler.com/check/s3_bucket_object_public" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "s3_bucket_acl_prohibited" + ], + "Notes": "Disabled by default. Configure s3_bucket_object_public_enabled, s3_bucket_object_public_max_objects, and s3_bucket_object_public_sample_size in the Prowler configuration. Because only a sample of objects is inspected, a PASS does not guarantee the bucket is free of public objects; use s3_bucket_acl_prohibited for full assurance." +} diff --git a/prowler/providers/aws/services/s3/s3_bucket_object_public/s3_bucket_object_public.py b/prowler/providers/aws/services/s3/s3_bucket_object_public/s3_bucket_object_public.py new file mode 100644 index 0000000000..9912da561b --- /dev/null +++ b/prowler/providers/aws/services/s3/s3_bucket_object_public/s3_bucket_object_public.py @@ -0,0 +1,82 @@ +from typing import List + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.s3.s3_client import s3_client + +# ACL grantee groups that make an object effectively public. AllUsers is anyone on +# the internet; AuthenticatedUsers is any authenticated AWS principal (any account). +PUBLIC_ACL_URIS = { + "http://acs.amazonaws.com/groups/global/AllUsers", + "http://acs.amazonaws.com/groups/global/AuthenticatedUsers", +} + + +class s3_bucket_object_public(Check): + """Spot-check a sample of S3 bucket objects for public ACL grants.""" + + def execute(self) -> List[Check_Report_AWS]: + """Evaluate sampled object ACLs for AllUsers/AuthenticatedUsers grants. + + Returns: + List[Check_Report_AWS]: One report per sampled bucket (empty when the + check is disabled via configuration). + """ + findings = [] + + if not s3_client.audit_config.get("s3_bucket_object_public_enabled", False): + return findings + + for bucket in s3_client.buckets.values(): + sampling = bucket.object_sampling + # Sampling is populated by the service layer only when the check is + # enabled; skip any bucket that was not sampled. + if sampling is None or not sampling.performed: + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=bucket) + + if sampling.error_code is not None: + report.status = "MANUAL" + if sampling.error_code == "AccessDenied": + report.status_extended = ( + f"Access Denied when spot-checking objects in bucket " + f"{bucket.name}." + ) + else: + report.status_extended = ( + f"Could not spot-check objects in bucket {bucket.name}: " + f"{sampling.error_message}." + ) + elif sampling.is_empty: + report.status = "PASS" + report.status_extended = f"S3 Bucket {bucket.name} is empty." + else: + public_objects = [ + obj.key + for obj in sampling.objects + if any( + grantee.type == "Group" and grantee.URI in PUBLIC_ACL_URIS + for grantee in obj.grantees + ) + ] + sampled = len(sampling.objects) + + if public_objects: + report.status = "FAIL" + report.status_extended = ( + f"S3 Bucket {bucket.name} has public objects detected in " + f"spot-check sample of {sampled} objects: " + f"{', '.join(public_objects)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"No public objects detected in spot-check sample of " + f"{sampled} objects in bucket {bucket.name}. For complete " + f"assurance, ensure ACLs are disabled via Object Ownership " + f"settings." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/s3/s3_service.py b/prowler/providers/aws/services/s3/s3_service.py index 00605fbe6e..94768138df 100644 --- a/prowler/providers/aws/services/s3/s3_service.py +++ b/prowler/providers/aws/services/s3/s3_service.py @@ -36,6 +36,10 @@ class S3(AWSService): self.__threading_call__( self._get_bucket_notification_configuration, self.buckets.values() ) + # Object-level ACL sampling is expensive and opt-in, so only run it when + # the s3_bucket_object_public check is explicitly enabled in the config. + if self.audit_config.get("s3_bucket_object_public_enabled", False): + self.__threading_call__(self._get_public_objects, self.buckets.values()) def _list_buckets(self, provider): logger.info("S3 - Listing buckets...") @@ -487,6 +491,69 @@ class S3(AWSService): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_public_objects(self, bucket): + logger.info("S3 - Spot-checking bucket objects for public ACLs...") + max_objects = self.audit_config.get("s3_bucket_object_public_max_objects", 100) + sample_size = self.audit_config.get("s3_bucket_object_public_sample_size", 3) + # Guard against misconfigured non-positive values: a zero sample size would + # raise ZeroDivisionError and a negative one would silently sample nothing. + if not isinstance(max_objects, int) or max_objects <= 0: + max_objects = 100 + if not isinstance(sample_size, int) or sample_size <= 0: + sample_size = 3 + sampling = BucketObjectSampling(performed=True) + regional_client = None + try: + regional_client = self.regional_clients[bucket.region] + contents = regional_client.list_objects_v2( + Bucket=bucket.name, MaxKeys=max_objects + ).get("Contents", []) + + if not contents: + sampling.is_empty = True + bucket.object_sampling = sampling + return + + all_keys = [obj["Key"] for obj in contents] + # Deterministic, evenly-spaced sampling so findings are reproducible + # across scans instead of flipping between PASS/FAIL with a random sample. + if len(all_keys) <= sample_size: + sample_keys = all_keys + else: + step = len(all_keys) // sample_size + sample_keys = [all_keys[i * step] for i in range(sample_size)] + + for key in sample_keys: + acl = regional_client.get_object_acl(Bucket=bucket.name, Key=key) + grantees = [] + for grant in acl.get("Grants", []): + grant_grantee = grant.get("Grantee", {}) + grantee = ACL_Grantee(type=grant_grantee.get("Type", "")) + grantee.display_name = grant_grantee.get("DisplayName") + grantee.ID = grant_grantee.get("ID") + grantee.URI = grant_grantee.get("URI") + grantee.permission = grant.get("Permission") + grantees.append(grantee) + sampling.objects.append(ObjectACL(key=key, grantees=grantees)) + + bucket.object_sampling = sampling + except ClientError as error: + sampling.error_code = error.response["Error"]["Code"] + sampling.error_message = str(error) + bucket.object_sampling = sampling + region = regional_client.region if regional_client else bucket.region + logger.warning( + f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + sampling.error_code = error.__class__.__name__ + sampling.error_message = str(error) + bucket.object_sampling = sampling + region = regional_client.region if regional_client else bucket.region + logger.error( + f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _head_bucket(self, bucket_name): logger.info("S3 - Checking if bucket exists...") try: @@ -654,6 +721,19 @@ class PublicAccessBlock(BaseModel): restrict_public_buckets: bool +class ObjectACL(BaseModel): + key: str + grantees: List[ACL_Grantee] = Field(default_factory=list) + + +class BucketObjectSampling(BaseModel): + performed: bool = False + is_empty: bool = False + objects: List[ObjectACL] = Field(default_factory=list) + error_code: Optional[str] = None + error_message: Optional[str] = None + + class AccessPoint(BaseModel): arn: str account_id: str @@ -703,3 +783,4 @@ class Bucket(BaseModel): lifecycle: List[LifeCycleRule] = Field(default_factory=list) replication_rules: List[ReplicationRule] = Field(default_factory=list) notification_config: Dict = Field(default_factory=dict) + object_sampling: Optional[BucketObjectSampling] = None diff --git a/prowler/providers/aws/services/ssm/ssm_document_secrets/ssm_document_secrets.py b/prowler/providers/aws/services/ssm/ssm_document_secrets/ssm_document_secrets.py index 0ec8502bab..1755c6f736 100644 --- a/prowler/providers/aws/services/ssm/ssm_document_secrets/ssm_document_secrets.py +++ b/prowler/providers/aws/services/ssm/ssm_document_secrets/ssm_document_secrets.py @@ -1,7 +1,11 @@ import json from prowler.lib.check.models import Check, Check_Report_AWS -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.ssm.ssm_client import ssm_client @@ -11,7 +15,26 @@ class ssm_document_secrets(Check): secrets_ignore_patterns = ssm_client.audit_config.get( "secrets_ignore_patterns", [] ) - for document in ssm_client.documents.values(): + validate = ssm_client.audit_config.get("secrets_validate", False) + documents = list(ssm_client.documents.values()) + + # Collect one payload per document (its content) and scan them all in + # batched Kingfisher invocations instead of one subprocess per document. + def payloads(): + for index, document in enumerate(documents): + if document.content: + yield index, json.dumps(document.content, indent=2) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, document in enumerate(documents): report = Check_Report_AWS(metadata=self.metadata(), resource=document) report.status = "PASS" report.status_extended = ( @@ -19,13 +42,15 @@ class ssm_document_secrets(Check): ) if document.content: - detect_secrets_output = detect_secrets_scan( - data=json.dumps(document.content, indent=2), - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=ssm_client.audit_config.get( - "detect_secrets_plugins" - ), - ) + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan SSM Document {document.name} for secrets: " + f"{scan_error}; manual review is required." + ) + findings.append(report) + continue + detect_secrets_output = batch_results.get(index) if detect_secrets_output: secrets_string = ", ".join( [ @@ -35,6 +60,7 @@ class ssm_document_secrets(Check): ) report.status = "FAIL" report.status_extended = f"Potential secret found in SSM Document {document.name} -> {secrets_string}." + annotate_verified_secrets(report, detect_secrets_output) findings.append(report) diff --git a/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/__init__.py b/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk.metadata.json b/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk.metadata.json new file mode 100644 index 0000000000..14b65b886b --- /dev/null +++ b/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "stepfunctions_statemachine_encrypted_with_cmk", + "CheckTitle": "Step Functions state machine is encrypted at rest with a customer-managed KMS key", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53 Controls (USA)" + ], + "ServiceName": "stepfunctions", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsStepFunctionStateMachine", + "ResourceGroup": "serverless", + "Description": "**AWS Step Functions state machines** store execution history and input/output data passed between workflow states. This check verifies that each state machine uses a **customer-managed KMS key** (`CUSTOMER_MANAGED_KMS_KEY`) for encryption at rest rather than the default AWS-owned key.", + "Risk": "Without a customer-managed KMS key, execution history containing **sensitive input/output data** is protected only by an AWS-owned key you cannot control, rotate, or revoke. This limits **auditability** via CloudTrail, prevents independent access revocation, and weakens **confidentiality** for regulated workloads.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/step-functions/latest/dg/encryption-at-rest.html", + "https://docs.aws.amazon.com/kms/latest/developerguide/concepts.html#customer-cmk" + ], + "Remediation": { + "Code": { + "CLI": "aws stepfunctions update-state-machine --state-machine-arn --encryption-configuration '{\"kmsKeyId\": \"\", \"type\": \"CUSTOMER_MANAGED_KMS_KEY\", \"kmsDataKeyReusePeriodSeconds\": 300}'", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::StepFunctions::StateMachine\n Properties:\n RoleArn: arn:aws:iam:::role/\n DefinitionString: |\n {\"StartAt\":\"Pass\",\"States\":{\"Pass\":{\"Type\":\"Pass\",\"End\":true}}}\n EncryptionConfiguration:\n KmsKeyId: arn:aws:kms:::key/ # Critical: customer-managed KMS key\n Type: CUSTOMER_MANAGED_KMS_KEY # Critical: must be CUSTOMER_MANAGED_KMS_KEY\n KmsDataKeyReusePeriodSeconds: 300\n```", + "Other": "1. Open AWS Console > Step Functions > State machines\n2. Select the state machine and click Edit\n3. Under Encryption, select Customer managed key\n4. Choose an existing KMS key or create a new one\n5. Save changes", + "Terraform": "```hcl\nresource \"aws_sfn_state_machine\" \"\" {\n name = \"\"\n role_arn = \"arn:aws:iam:::role/\"\n definition = jsonencode({ StartAt = \"Pass\", States = { Pass = { Type = \"Pass\", End = true } } })\n\n encryption_configuration {\n kms_key_id = \"arn:aws:kms:::key/\" # Critical: customer-managed KMS key\n type = \"CUSTOMER_MANAGED_KMS_KEY\" # Critical: must be CUSTOMER_MANAGED_KMS_KEY\n kms_data_key_reuse_period_seconds = 300\n }\n}\n```" + }, + "Recommendation": { + "Text": "Configure each Step Functions state machine to use a **customer-managed KMS key** for encryption at rest. Assign a least-privilege key policy, enable **automatic key rotation**, and grant the execution role `kms:GenerateDataKey` and `kms:Decrypt`. Monitor key usage via CloudTrail.", + "Url": "https://hub.prowler.com/check/stepfunctions_statemachine_encrypted_with_cmk" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "stepfunctions_statemachine_logging_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk.py b/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk.py new file mode 100644 index 0000000000..b14de0b482 --- /dev/null +++ b/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk.py @@ -0,0 +1,50 @@ +from typing import List + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.stepfunctions.stepfunctions_client import ( + stepfunctions_client, +) +from prowler.providers.aws.services.stepfunctions.stepfunctions_service import ( + EncryptionType, +) + + +class stepfunctions_statemachine_encrypted_with_cmk(Check): + """Ensure Step Functions state machines are encrypted at rest with a customer-managed KMS key. + + This check evaluates whether each AWS Step Functions state machine uses a + customer-managed KMS key (CUSTOMER_MANAGED_KMS_KEY) for encryption at rest rather + than the default AWS-owned key (AWS_OWNED_KEY). + + - PASS: The state machine encryption_configuration type is CUSTOMER_MANAGED_KMS_KEY. + - FAIL: The state machine has no encryption_configuration or its type is AWS_OWNED_KEY. + """ + + def execute(self) -> List[Check_Report_AWS]: + """Execute the Step Functions state machine encryption at rest check. + + Iterates over all Step Functions state machines and generates a report + indicating whether each state machine uses a customer-managed KMS key + for encryption at rest. + + Returns: + List[Check_Report_AWS]: A list of report objects with the results of the check. + """ + findings = [] + for state_machine in stepfunctions_client.state_machines.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=state_machine) + + if ( + state_machine.encryption_configuration + and state_machine.encryption_configuration.type + == EncryptionType.CUSTOMER_MANAGED_KMS_KEY + ): + report.status = "PASS" + report.status_extended = f"Step Functions state machine {state_machine.name} is encrypted at rest with a customer-managed KMS key." + else: + report.status = "FAIL" + report.status_extended = f"Step Functions state machine {state_machine.name} is not encrypted at rest with a customer-managed KMS key." + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/stepfunctions_statemachine_no_secrets_in_definition.py b/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/stepfunctions_statemachine_no_secrets_in_definition.py index db04710029..12934528e9 100644 --- a/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/stepfunctions_statemachine_no_secrets_in_definition.py +++ b/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/stepfunctions_statemachine_no_secrets_in_definition.py @@ -1,5 +1,9 @@ from prowler.lib.check.models import Check, Check_Report_AWS -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.stepfunctions.stepfunctions_client import ( stepfunctions_client, ) @@ -13,20 +17,41 @@ class stepfunctions_statemachine_no_secrets_in_definition(Check): secrets_ignore_patterns = stepfunctions_client.audit_config.get( "secrets_ignore_patterns", [] ) - for state_machine in stepfunctions_client.state_machines.values(): + validate = stepfunctions_client.audit_config.get("secrets_validate", False) + state_machines = list(stepfunctions_client.state_machines.values()) + + # Collect one payload per state machine (its definition) and scan them + # all in batched Kingfisher invocations instead of one subprocess each. + def payloads(): + for index, state_machine in enumerate(state_machines): + if state_machine.definition: + yield index, state_machine.definition + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, state_machine in enumerate(state_machines): report = Check_Report_AWS(metadata=self.metadata(), resource=state_machine) report.status = "PASS" report.status_extended = f"No secrets found in Step Functions state machine {state_machine.name} definition." if state_machine.definition: - detect_secrets_output = detect_secrets_scan( - data=state_machine.definition, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=stepfunctions_client.audit_config.get( - "detect_secrets_plugins", - ), - ) - + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan Step Functions state machine " + f"{state_machine.name} definition for secrets: {scan_error}; " + "manual review is required." + ) + findings.append(report) + continue + detect_secrets_output = batch_results.get(index) if detect_secrets_output: secrets_string = ", ".join( [ @@ -40,6 +65,7 @@ class stepfunctions_statemachine_no_secrets_in_definition(Check): f"found in Step Functions state machine {state_machine.name} definition " f"-> {secrets_string}." ) + annotate_verified_secrets(report, detect_secrets_output) findings.append(report) return findings diff --git a/prowler/providers/aws/services/waf/waf_regional_webacl_logging_enabled/__init__.py b/prowler/providers/aws/services/waf/waf_regional_webacl_logging_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/waf/waf_regional_webacl_logging_enabled/waf_regional_webacl_logging_enabled.metadata.json b/prowler/providers/aws/services/waf/waf_regional_webacl_logging_enabled/waf_regional_webacl_logging_enabled.metadata.json new file mode 100644 index 0000000000..b213398059 --- /dev/null +++ b/prowler/providers/aws/services/waf/waf_regional_webacl_logging_enabled/waf_regional_webacl_logging_enabled.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "waf_regional_webacl_logging_enabled", + "CheckTitle": "AWS WAF Classic Regional Web ACL has logging enabled", + "CheckType": [ + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53 Controls (USA)", + "Software and Configuration Checks/Industry and Regulatory Standards/PCI-DSS" + ], + "ServiceName": "waf", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsWafRegionalWebAcl", + "ResourceGroup": "security", + "Description": "**AWS WAF Classic Regional Web ACLs** are evaluated for **logging** enabled to capture evaluated web requests and rule actions. Regional Web ACLs protect Application Load Balancers and API Gateway stages.", + "Risk": "Without **WAF logging**, you lose **visibility** into attacks (SQLi/XSS probes, bots, brute-force) and into allow/block decisions for ALB and API Gateway traffic. This limits detection, forensics, and incident response, weakening **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/waf/latest/developerguide/classic-logging.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/waf-controls.html", + "https://docs.aws.amazon.com/cli/latest/reference/waf-regional/put-logging-configuration.html" + ], + "Remediation": { + "Code": { + "CLI": "aws waf-regional put-logging-configuration --logging-configuration ResourceArn=,LogDestinationConfigs= --region ", + "NativeIaC": "", + "Other": "1. Create an Amazon Kinesis Data Firehose delivery stream with a name starting with \"aws-waf-logs-\" in the same region as your Web ACL\n2. Open the AWS WAF console and switch to AWS WAF Classic\n3. Select Filter: Regional (your region) and go to Web ACLs\n4. Open the target Web ACL and go to the Logging tab\n5. Click Enable logging and select the Firehose delivery stream created in step 1\n6. Click Enable/Save", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable **logging** on all Regional Web ACLs and send records to a centralized logging platform. Apply **least privilege** to log destinations, redact sensitive fields, and monitor for anomalies. Integrate logs with incident response for **defense in depth** and faster containment.", + "Url": "https://hub.prowler.com/check/waf_regional_webacl_logging_enabled" + } + }, + "Categories": [ + "logging" + ], + "DependsOn": [], + "RelatedTo": [ + "waf_global_webacl_logging_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/waf/waf_regional_webacl_logging_enabled/waf_regional_webacl_logging_enabled.py b/prowler/providers/aws/services/waf/waf_regional_webacl_logging_enabled/waf_regional_webacl_logging_enabled.py new file mode 100644 index 0000000000..8d832d6c1e --- /dev/null +++ b/prowler/providers/aws/services/waf/waf_regional_webacl_logging_enabled/waf_regional_webacl_logging_enabled.py @@ -0,0 +1,43 @@ +from typing import List + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.waf.wafregional_client import wafregional_client + + +class waf_regional_webacl_logging_enabled(Check): + """Ensure AWS WAF Classic Regional Web ACLs have logging enabled. + + This check evaluates whether each AWS WAF Classic Regional Web ACL has logging + enabled by verifying the presence of at least one log destination configured + in its logging configuration. + + - PASS: The Web ACL has at least one log destination configured. + - FAIL: The Web ACL has no log destinations configured (logging is disabled). + """ + + def execute(self) -> List[Check_Report_AWS]: + """Execute the WAF Regional Web ACL logging enabled check. + + Iterates over all WAF Classic Regional Web ACLs and generates a report + indicating whether each Web ACL has logging enabled. + + Returns: + List[Check_Report_AWS]: A list of report objects with the results of the check. + """ + findings = [] + for acl in wafregional_client.web_acls.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=acl) + report.status = "FAIL" + report.status_extended = ( + f"AWS WAF Regional Web ACL {acl.name} does not have logging enabled." + ) + + if acl.logging_enabled: + report.status = "PASS" + report.status_extended = ( + f"AWS WAF Regional Web ACL {acl.name} does have logging enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/waf/waf_service.py b/prowler/providers/aws/services/waf/waf_service.py index 25476e6858..602b7116f6 100644 --- a/prowler/providers/aws/services/waf/waf_service.py +++ b/prowler/providers/aws/services/waf/waf_service.py @@ -168,6 +168,7 @@ class WAFRegional(AWSService): ) self.__threading_call__(self._list_web_acls) self.__threading_call__(self._get_web_acl, self.web_acls.values()) + self.__threading_call__(self._get_logging_configuration, self.web_acls.values()) self.__threading_call__(self._list_resources_for_web_acl) def _list_rules(self, regional_client): @@ -277,6 +278,34 @@ class WAFRegional(AWSService): f"{acl.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_logging_configuration(self, acl): + """Fetch and store the logging configuration for a Regional Web ACL. + + Calls the WAF Regional GetLoggingConfiguration API for the given ACL and + sets acl.logging_enabled to True if at least one log destination is configured, + False otherwise. + + Args: + acl (WebAcl): The Regional Web ACL instance to update. + """ + logger.info( + f"WAFRegional - Getting Regional Web ACL {acl.name} logging configuration..." + ) + try: + get_logging_configuration = self.regional_clients[ + acl.region + ].get_logging_configuration(ResourceArn=acl.arn) + acl.logging_enabled = bool( + get_logging_configuration.get("LoggingConfiguration", {}).get( + "LogDestinationConfigs", [] + ) + ) + + except Exception as error: + logger.error( + f"{acl.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _list_resources_for_web_acl(self, regional_client): logger.info("WAFRegional - Describing resources...") try: diff --git a/prowler/providers/azure/azure_provider.py b/prowler/providers/azure/azure_provider.py index c9496ac0a5..8b399cdc48 100644 --- a/prowler/providers/azure/azure_provider.py +++ b/prowler/providers/azure/azure_provider.py @@ -16,6 +16,7 @@ from azure.identity import ( DefaultAzureCredential, InteractiveBrowserCredential, ) +from azure.mgmt.resource import ResourceManagementClient from azure.mgmt.subscription import SubscriptionClient from colorama import Fore, Style from msgraph import GraphServiceClient @@ -97,12 +98,14 @@ class AzureProvider(Provider): """ _type: str = "azure" + sdk_only: bool = False _session: DefaultAzureCredential _identity: AzureIdentityInfo _audit_config: dict _region_config: AzureRegionConfig _locations: dict _mutelist: AzureMutelist + _resource_groups: dict[str, list[str]] # TODO: this is not optional, enforce for all providers audit_metadata: Audit_Metadata @@ -122,6 +125,7 @@ class AzureProvider(Provider): mutelist_content: dict = None, client_id: str = None, client_secret: str = None, + resource_groups: list = [], ): """ Initializes the Azure provider. @@ -141,6 +145,7 @@ class AzureProvider(Provider): mutelist_content (dict): The mutelist content. client_id (str): The Azure client ID. client_secret (str): The Azure client secret. + resource_groups (list): List of resource group names. Returns: None @@ -205,7 +210,7 @@ class AzureProvider(Provider): ... managed_identity_auth=False, ... region="AzureUSGovernment", ... ) - - Subscriptions: rowler is multisubscription, which means that is going to scan all the subscriptions is able to list. If you only assign permissions to one subscription, it is going to scan a single one. + - Subscriptions: Prowler is multisubscription, which means that is going to scan all the subscriptions is able to list. If you only assign permissions to one subscription, it is going to scan a single one. Prowler also allows you to specify the subscriptions you want to scan by passing a list of subscription IDs. >>> AzureProvider( ... az_cli_auth=False, @@ -214,6 +219,11 @@ class AzureProvider(Provider): ... managed_identity_auth=False, ... subscription_ids=["XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"], ... ) + - Resource Groups: Prowler allows you to narrow the scan to specific resource groups. + >>> AzureProvider( + ... az_cli_auth=True, + ... resource_groups=["rg-production", "rg-staging"], + ... ) """ logger.info("Setting Azure provider ...") @@ -271,6 +281,8 @@ class AzureProvider(Provider): # TODO: should we keep this here or within the identity? self._locations = self.get_locations() + self._resource_groups = self.validate_resource_groups(resource_groups) + # Audit Config if config_content: self._audit_config = config_content @@ -336,6 +348,11 @@ class AzureProvider(Provider): """Mutelist object associated with this Azure provider.""" return self._mutelist + @property + def resource_groups(self) -> dict[str, list[str]]: + """Mapping of subscription name to the list of resource groups to scan within it.""" + return self._resource_groups + # TODO: this should be moved to the argparse, if not we need to enforce it from the Provider # previously was using the AzureException @staticmethod @@ -438,7 +455,7 @@ class AzureProvider(Provider): """Azure credentials information. This method prints the Azure Tenant Domain, Azure Tenant ID, Azure Region, - Azure Subscriptions, Azure Identity Type, and Azure Identity ID. + Azure Subscriptions, Azure Resource Groups, Azure Identity Type, and Azure Identity ID. Args: None @@ -454,6 +471,7 @@ class AzureProvider(Provider): f"Azure Tenant Domain: {Fore.YELLOW}{self._identity.tenant_domain}{Style.RESET_ALL} Azure Tenant ID: {Fore.YELLOW}{self._identity.tenant_ids[0]}{Style.RESET_ALL}", f"Azure Region: {Fore.YELLOW}{self.region_config.name}{Style.RESET_ALL}", f"Azure Subscriptions: {Fore.YELLOW}{printed_subscriptions}{Style.RESET_ALL}", + f"Azure Resource Groups: {Fore.YELLOW}{sorted({rg for rgs in self._resource_groups.values() for rg in rgs}) if any(self._resource_groups.values()) else ('NONE (no matching resource groups found)' if self._resource_groups else 'ALL')}{Style.RESET_ALL}", f"Azure Identity Type: {Fore.YELLOW}{self._identity.identity_type}{Style.RESET_ALL} Azure Identity ID: {Fore.YELLOW}{self._identity.identity_id}{Style.RESET_ALL}", ] report_title = ( @@ -1101,6 +1119,54 @@ class AzureProvider(Provider): return set(chain.from_iterable(locations.values())) + def validate_resource_groups(self, resource_groups: list) -> dict[str, list[str]]: + resource_groups = [r.strip() for r in resource_groups if r and r.strip()] + if not resource_groups: + return {} + + rg_map = { + subscription_id: [] for subscription_id in self._identity.subscriptions + } + credentials = self.session + + for subscription_id, display_name in self._identity.subscriptions.items(): + try: + rg_client = ResourceManagementClient( + credentials, + subscription_id, + base_url=self._region_config.base_url, + credential_scopes=self._region_config.credential_scopes, + ) + existing_rgs = { + rg.name.lower(): rg.name for rg in rg_client.resource_groups.list() + } + except Exception as e: + logger.warning( + f"Could not list resource groups for subscription '{display_name}' " + f"({subscription_id}): {e}. Skipping resource group filtering for this subscription." + ) + continue + + for rg in resource_groups: + real_name = existing_rgs.get(rg.lower()) + if real_name: + rg_map[subscription_id].append(real_name) + + for rg in resource_groups: + if not any(rg.lower() == r.lower() for rgs in rg_map.values() for r in rgs): + logger.warning( + f"Resource group '{rg}' was not found in any subscription. " + "Please check the resource group name and try again." + ) + + if not any(rgs for rgs in rg_map.values()): + logger.warning( + f"None of the provided resource groups {resource_groups} were found " + "in any subscription. Please check the resource group names and try again." + ) + + return rg_map + @staticmethod def validate_static_credentials( tenant_id: str = None, diff --git a/prowler/providers/azure/lib/arguments/arguments.py b/prowler/providers/azure/lib/arguments/arguments.py index 2b624a3f23..87bea948aa 100644 --- a/prowler/providers/azure/lib/arguments/arguments.py +++ b/prowler/providers/azure/lib/arguments/arguments.py @@ -53,6 +53,16 @@ def init_parser(self): type=validate_azure_region, help="Azure region from `az cloud list --output table`, by default AzureCloud", ) + # Resource Groups + azure_rg_subparser = azure_parser.add_argument_group("Resource Groups") + azure_rg_subparser.add_argument( + "--azure-resource-group", + "--azure-resource-groups", + nargs="+", + default=[], + dest="resource_groups", + help="Azure Resource Group names to scope the scan to specific groups.", + ) def validate_azure_region(region): diff --git a/prowler/providers/azure/lib/service/service.py b/prowler/providers/azure/lib/service/service.py index a0a832ca01..ae9b127647 100644 --- a/prowler/providers/azure/lib/service/service.py +++ b/prowler/providers/azure/lib/service/service.py @@ -26,6 +26,7 @@ class AzureService: ) self.subscriptions = provider.identity.subscriptions + self.resource_groups = provider.resource_groups self.locations = provider.locations self.audit_config = provider.audit_config self.fixer_config = provider.fixer_config @@ -49,6 +50,26 @@ class AzureService: return results + def list_with_rg_scope(self, subscription_id, list_all_fn, list_by_rg_fn): + if not self.resource_groups: + return list(list_all_fn()) + resource_groups = self.resource_groups.get(subscription_id, []) + if not resource_groups: + logger.info( + f"No valid resource groups for subscription {subscription_id}, skipping." + ) + return [] + output = [] + for resource_group in resource_groups: + try: + output += list(list_by_rg_fn(resource_group_name=resource_group)) + except Exception as error: + logger.warning( + f"Subscription ID: {subscription_id} -- Resource Group: {resource_group} -- " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return output + def __set_clients__(self, identity, session, service, region_config): clients = {} try: diff --git a/prowler/providers/azure/services/aisearch/aisearch_service.py b/prowler/providers/azure/services/aisearch/aisearch_service.py index 3f482a41a5..01c6a458f7 100644 --- a/prowler/providers/azure/services/aisearch/aisearch_service.py +++ b/prowler/providers/azure/services/aisearch/aisearch_service.py @@ -17,7 +17,11 @@ class AISearch(AzureService): for subscription, client in self.clients.items(): try: aisearch_services.update({subscription: {}}) - aisearch_services_list = client.services.list_by_subscription() + aisearch_services_list = self.list_with_rg_scope( + subscription, + client.services.list_by_subscription, + client.services.list_by_resource_group, + ) for aisearch_service in aisearch_services_list: aisearch_services[subscription].update( { diff --git a/prowler/providers/azure/services/aks/aks_service.py b/prowler/providers/azure/services/aks/aks_service.py index 081edd7b17..0bd7c4d63c 100644 --- a/prowler/providers/azure/services/aks/aks_service.py +++ b/prowler/providers/azure/services/aks/aks_service.py @@ -19,8 +19,12 @@ class AKS(AzureService): for subscription_id, client in self.clients.items(): try: - clusters_list = client.managed_clusters.list() clusters.update({subscription_id: {}}) + clusters_list = self.list_with_rg_scope( + subscription_id, + client.managed_clusters.list, + client.managed_clusters.list_by_resource_group, + ) for cluster in clusters_list: if getattr(cluster, "kubernetes_version", None): diff --git a/prowler/providers/azure/services/apim/apim_service.py b/prowler/providers/azure/services/apim/apim_service.py index 98fb00f276..3186716ddb 100644 --- a/prowler/providers/azure/services/apim/apim_service.py +++ b/prowler/providers/azure/services/apim/apim_service.py @@ -131,7 +131,11 @@ class APIM(AzureService): for subscription, client in self.clients.items(): try: instances.update({subscription: []}) - apim_instances = client.api_management_service.list() + apim_instances = self.list_with_rg_scope( + subscription, + client.api_management_service.list, + client.api_management_service.list_by_resource_group, + ) for instance in apim_instances: workspace_id = self._get_log_analytics_workspace_id( diff --git a/prowler/providers/azure/services/app/app_service.py b/prowler/providers/azure/services/app/app_service.py index 201cd6a344..83d23be516 100644 --- a/prowler/providers/azure/services/app/app_service.py +++ b/prowler/providers/azure/services/app/app_service.py @@ -22,8 +22,12 @@ class App(AzureService): for subscription_id, client in self.clients.items(): try: - apps_list = client.web_apps.list() apps.update({subscription_id: {}}) + apps_list = self.list_with_rg_scope( + subscription_id, + client.web_apps.list, + client.web_apps.list_by_resource_group, + ) for app in apps_list: # Filter function apps @@ -117,8 +121,12 @@ class App(AzureService): for subscription_id, client in self.clients.items(): try: - functions_list = client.web_apps.list() functions.update({subscription_id: {}}) + functions_list = self.list_with_rg_scope( + subscription_id, + client.web_apps.list, + client.web_apps.list_by_resource_group, + ) for function in functions_list: # Filter function apps diff --git a/prowler/providers/azure/services/appinsights/appinsights_service.py b/prowler/providers/azure/services/appinsights/appinsights_service.py index 918a0f1b0f..6e92a7b275 100644 --- a/prowler/providers/azure/services/appinsights/appinsights_service.py +++ b/prowler/providers/azure/services/appinsights/appinsights_service.py @@ -17,8 +17,12 @@ class AppInsights(AzureService): for subscription_id, client in self.clients.items(): try: - components_list = client.components.list() components.update({subscription_id: {}}) + components_list = self.list_with_rg_scope( + subscription_id, + client.components.list, + client.components.list_by_resource_group, + ) for component in components_list: components[subscription_id].update( diff --git a/prowler/providers/azure/services/containerregistry/containerregistry_service.py b/prowler/providers/azure/services/containerregistry/containerregistry_service.py index ee6cce39f2..c44a0c7ef1 100644 --- a/prowler/providers/azure/services/containerregistry/containerregistry_service.py +++ b/prowler/providers/azure/services/containerregistry/containerregistry_service.py @@ -19,8 +19,12 @@ class ContainerRegistry(AzureService): registries = {} for subscription, client in self.clients.items(): try: - registries_list = client.registries.list() registries.update({subscription: {}}) + registries_list = self.list_with_rg_scope( + subscription, + client.registries.list, + client.registries.list_by_resource_group, + ) for registry in registries_list: resource_group = self._get_resource_group(registry.id) diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py index e7c53799a7..37b06da1c5 100644 --- a/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py @@ -18,8 +18,13 @@ class CosmosDB(AzureService): accounts = {} for subscription, client in self.clients.items(): try: - accounts_list = client.database_accounts.list() accounts.update({subscription: []}) + accounts_list = self.list_with_rg_scope( + subscription, + client.database_accounts.list, + client.database_accounts.list_by_resource_group, + ) + for account in accounts_list: accounts[subscription].append( Account( diff --git a/prowler/providers/azure/services/databricks/databricks_service.py b/prowler/providers/azure/services/databricks/databricks_service.py index b7367d3cbb..128495a2c7 100644 --- a/prowler/providers/azure/services/databricks/databricks_service.py +++ b/prowler/providers/azure/services/databricks/databricks_service.py @@ -38,8 +38,13 @@ class Databricks(AzureService): for subscription, client in self.clients.items(): try: workspaces[subscription] = {} + workspaces_list = self.list_with_rg_scope( + subscription, + client.workspaces.list_by_subscription, + client.workspaces.list_by_resource_group, + ) - for workspace in client.workspaces.list_by_subscription(): + for workspace in workspaces_list: workspace_parameters = getattr(workspace, "parameters", None) workspace_managed_disk_encryption = getattr( getattr( diff --git a/prowler/providers/azure/services/defender/defender_service.py b/prowler/providers/azure/services/defender/defender_service.py index 7da96cd8ec..b4ce4239cc 100644 --- a/prowler/providers/azure/services/defender/defender_service.py +++ b/prowler/providers/azure/services/defender/defender_service.py @@ -230,8 +230,10 @@ class Defender(AzureService): iot_security_solutions = {} for subscription_id, client in self.clients.items(): try: - iot_security_solutions_list = ( - client.iot_security_solution.list_by_subscription() + iot_security_solutions_list = self.list_with_rg_scope( + subscription_id, + client.iot_security_solution.list_by_subscription, + client.iot_security_solution.list_by_resource_group, ) iot_security_solutions.update({subscription_id: {}}) for iot_security_solution in iot_security_solutions_list: @@ -267,8 +269,13 @@ class Defender(AzureService): for subscription_id, client in self.clients.items(): try: jit_policies[subscription_id] = {} - policies = client.jit_network_access_policies.list() - for policy in policies: + policies_list = self.list_with_rg_scope( + subscription_id, + client.jit_network_access_policies.list, + client.jit_network_access_policies.list_by_resource_group, + ) + + for policy in policies_list: vm_ids = set() for vm in getattr(policy, "virtual_machines", []): vm_ids.add(vm.id) diff --git a/prowler/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled.metadata.json b/prowler/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled.metadata.json index 164a43b5da..241c95ec7f 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled.metadata.json +++ b/prowler/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled.metadata.json @@ -9,7 +9,7 @@ "Severity": "high", "ResourceType": "microsoft.keyvault/vaults", "ResourceGroup": "security", - "Description": "**Azure Key Vault** diagnostic settings capture **audit logs** (`AuditEvent`) when category groups `audit` and `allLogs` are enabled and routed to a supported destination. Logged events include management and data-plane operations on vaults, keys, secrets, and certificates.", + "Description": "**Azure Key Vault** diagnostic settings capture **audit logs** (`AuditEvent`) when the `AuditEvent` category is enabled, or when category groups `audit` and `allLogs` are enabled, and routed to a supported destination. Logged events include management and data-plane operations on vaults, keys, secrets, and certificates.", "Risk": "Without **Key Vault audit logging**, access and changes to keys, secrets, and certificates are untracked.\n\nAttackers can misuse keys to decrypt data, alter or delete crypto material, and evade detection-eroding **confidentiality** and **integrity** and delaying **incident response**.", "RelatedUrl": "", "AdditionalURLs": [ @@ -20,13 +20,13 @@ ], "Remediation": { "Code": { - "CLI": "az monitor diagnostic-settings create --name --resource --workspace --logs '[{\"categoryGroup\":\"audit\",\"enabled\":true},{\"categoryGroup\":\"allLogs\",\"enabled\":true}]'", - "NativeIaC": "```bicep\n// Enable Key Vault diagnostic settings with audit + allLogs\nparam keyVaultName string\nparam workspaceId string\n\nresource kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = {\n name: keyVaultName\n}\n\nresource diag 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {\n name: ''\n scope: kv\n properties: {\n workspaceId: workspaceId\n logs: [\n {\n categoryGroup: 'audit' // critical: enables audit logs\n enabled: true // required to pass the check\n }\n {\n categoryGroup: 'allLogs' // critical: enables allLogs group\n enabled: true // required to pass the check\n }\n ]\n }\n}\n```", - "Other": "1. In Azure Portal, go to your Key Vault > Monitoring > Diagnostic settings\n2. Click Add diagnostic setting\n3. Under Category groups, select audit and allLogs\n4. Choose a destination (e.g., Send to Log Analytics workspace) and select the workspace\n5. Click Save", - "Terraform": "```hcl\n# Enable diagnostic settings on Key Vault with audit + allLogs\nresource \"azurerm_monitor_diagnostic_setting\" \"\" {\n name = \"\"\n target_resource_id = \"\" # Key Vault resource ID\n log_analytics_workspace_id = \"\" # Destination workspace ID\n\n enabled_log { # critical: audit category group\n category_group = \"audit\" # enables audit logs\n }\n enabled_log { # critical: allLogs category group\n category_group = \"allLogs\" # enables all logs\n }\n}\n```" + "CLI": "az monitor diagnostic-settings create --name --resource --workspace --logs '[{\"category\":\"AuditEvent\",\"enabled\":true}]'", + "NativeIaC": "```bicep\n// Enable Key Vault AuditEvent diagnostic logs\nparam keyVaultName string\nparam workspaceId string\n\nresource kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = {\n name: keyVaultName\n}\n\nresource diag 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {\n name: ''\n scope: kv\n properties: {\n workspaceId: workspaceId\n logs: [\n {\n category: 'AuditEvent'\n enabled: true\n }\n ]\n }\n}\n```", + "Other": "1. In Azure Portal, go to your Key Vault > Monitoring > Diagnostic settings\n2. Click Add diagnostic setting\n3. Enable AuditEvent audit logs, or select the audit and allLogs category groups\n4. Choose a destination (e.g., Send to Log Analytics workspace) and select the workspace\n5. Click Save", + "Terraform": "```hcl\n# Enable AuditEvent diagnostic logs on Key Vault\nresource \"azurerm_monitor_diagnostic_setting\" \"\" {\n name = \"\"\n target_resource_id = \"\"\n log_analytics_workspace_id = \"\"\n\n enabled_log {\n category = \"AuditEvent\"\n }\n}\n```" }, "Recommendation": { - "Text": "Enable **diagnostic settings** to collect `AuditEvent` logs-covering category groups `audit` and `allLogs`-and send them to a central sink. Apply **least privilege** to log access, enforce secure **retention/immutability**, monitor with alerts for anomalous operations, and use **separation of duties** to prevent logging bypass.", + "Text": "Enable **diagnostic settings** to collect `AuditEvent` logs and send them to a central sink. Apply **least privilege** to log access, enforce secure **retention/immutability**, monitor with alerts for anomalous operations, and use **separation of duties** to prevent logging bypass.", "Url": "https://hub.prowler.com/check/keyvault_logging_enabled" } }, diff --git a/prowler/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled.py b/prowler/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled.py index 68ef149cd7..ea80b920c5 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled.py +++ b/prowler/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled.py @@ -16,14 +16,17 @@ class keyvault_logging_enabled(Check): report.status = "FAIL" report.status_extended = f"Key Vault {keyvault.name} in subscription {subscription_name} ({subscription_id}) does not have a diagnostic setting with audit logging." for diagnostic_setting in keyvault.monitor_diagnostic_settings or []: - has_audit = False + has_audit_category = False + has_audit_group = False has_all_logs = False for log in diagnostic_setting.logs: + if log.category == "AuditEvent" and log.enabled: + has_audit_category = True if log.category_group == "audit" and log.enabled: - has_audit = True + has_audit_group = True if log.category_group == "allLogs" and log.enabled: has_all_logs = True - if has_audit and has_all_logs: + if has_audit_category or (has_audit_group and has_all_logs): report.status = "PASS" report.status_extended = f"Key Vault {keyvault.name} in subscription {subscription_name} ({subscription_id}) has a diagnostic setting with audit logging." break diff --git a/prowler/providers/azure/services/keyvault/keyvault_service.py b/prowler/providers/azure/services/keyvault/keyvault_service.py index 9fb3fd98af..e5b2e76427 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_service.py +++ b/prowler/providers/azure/services/keyvault/keyvault_service.py @@ -35,7 +35,11 @@ class KeyVault(AzureService): for subscription, client in self.clients.items(): try: key_vaults[subscription] = [] - vaults_list = list(client.vaults.list_by_subscription()) + vaults_list = self.list_with_rg_scope( + subscription, + client.vaults.list_by_subscription, + client.vaults.list_by_resource_group, + ) if not vaults_list: continue diff --git a/prowler/providers/azure/services/mysql/mysql_service.py b/prowler/providers/azure/services/mysql/mysql_service.py index b3a386a193..2898d8445c 100644 --- a/prowler/providers/azure/services/mysql/mysql_service.py +++ b/prowler/providers/azure/services/mysql/mysql_service.py @@ -19,8 +19,12 @@ class MySQL(AzureService): servers = {} for subscription_id, client in self.clients.items(): try: - servers_list = client.servers.list() servers.update({subscription_id: {}}) + servers_list = self.list_with_rg_scope( + subscription_id, + client.servers.list, + client.servers.list_by_resource_group, + ) for server in servers_list: backup = getattr(server, "backup", None) ha = getattr(server, "high_availability", None) diff --git a/prowler/providers/azure/services/network/network_service.py b/prowler/providers/azure/services/network/network_service.py index a924cf9609..54cb02989b 100644 --- a/prowler/providers/azure/services/network/network_service.py +++ b/prowler/providers/azure/services/network/network_service.py @@ -24,8 +24,13 @@ class Network(AzureService): security_groups = {} for subscription, client in self.clients.items(): try: + security_groups_list = self.list_with_rg_scope( + subscription, + client.network_security_groups.list_all, + client.network_security_groups.list, + ) + security_groups.update({subscription: []}) - security_groups_list = client.network_security_groups.list_all() for security_group in security_groups_list: security_groups[subscription].append( SecurityGroup( @@ -64,8 +69,8 @@ class Network(AzureService): network_watchers = {} for subscription, client in self.clients.items(): try: - network_watchers.update({subscription: []}) network_watchers_list = client.network_watchers.list_all() + network_watchers.update({subscription: []}) for network_watcher in network_watchers_list: flow_logs = self._get_flow_logs( subscription, network_watcher.name, network_watcher.id @@ -164,8 +169,13 @@ class Network(AzureService): bastion_hosts = {} for subscription, client in self.clients.items(): try: + bastion_hosts_list = self.list_with_rg_scope( + subscription, + client.bastion_hosts.list, + client.bastion_hosts.list_by_resource_group, + ) + bastion_hosts.update({subscription: []}) - bastion_hosts_list = client.bastion_hosts.list() for bastion_host in bastion_hosts_list: bastion_hosts[subscription].append( BastionHost( @@ -186,8 +196,13 @@ class Network(AzureService): public_ip_addresses = {} for subscription, client in self.clients.items(): try: + public_ip_addresses_list = self.list_with_rg_scope( + subscription, + client.public_ip_addresses.list_all, + client.public_ip_addresses.list, + ) + public_ip_addresses.update({subscription: []}) - public_ip_addresses_list = client.public_ip_addresses.list_all() for public_ip_address in public_ip_addresses_list: public_ip_addresses[subscription].append( PublicIp( @@ -207,13 +222,17 @@ class Network(AzureService): def _get_virtual_networks(self): logger.info("Network - Getting Virtual Networks...") virtual_networks = {} - for subscription, client in self.clients.items(): + for subscription_id, client in self.clients.items(): try: - virtual_networks[subscription] = [] - vnet_list = client.virtual_networks.list_all() - for vnet in vnet_list: + virtual_networks[subscription_id] = [] + virtual_networks_list = self.list_with_rg_scope( + subscription_id, + client.virtual_networks.list_all, + client.virtual_networks.list, + ) + for virtual_network in virtual_networks_list: subnets = [] - for subnet in getattr(vnet, "subnets", []) or []: + for subnet in getattr(virtual_network, "subnets", []) or []: nsg = getattr(subnet, "network_security_group", None) subnets.append( VNetSubnet( @@ -222,20 +241,20 @@ class Network(AzureService): nsg_id=getattr(nsg, "id", None) if nsg else None, ) ) - virtual_networks[subscription].append( + virtual_networks[subscription_id].append( VirtualNetwork( - id=vnet.id, - name=vnet.name, - location=vnet.location, + id=virtual_network.id, + name=virtual_network.name, + location=virtual_network.location, enable_ddos_protection=getattr( - vnet, "enable_ddos_protection", False + virtual_network, "enable_ddos_protection", False ), subnets=subnets, ) ) except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return virtual_networks diff --git a/prowler/providers/azure/services/policy/policy_service.py b/prowler/providers/azure/services/policy/policy_service.py index 1d1381202f..663e9b76a3 100644 --- a/prowler/providers/azure/services/policy/policy_service.py +++ b/prowler/providers/azure/services/policy/policy_service.py @@ -18,8 +18,8 @@ class Policy(AzureService): for subscription_id, client in self.clients.items(): try: - policy_assigments_list = client.policy_assignments.list() policy_assigments.update({subscription_id: {}}) + policy_assigments_list = client.policy_assignments.list() for policy_assigment in policy_assigments_list: policy_assigments[subscription_id].update( diff --git a/prowler/providers/azure/services/postgresql/postgresql_service.py b/prowler/providers/azure/services/postgresql/postgresql_service.py index 2048299bf1..e7fe98fe79 100644 --- a/prowler/providers/azure/services/postgresql/postgresql_service.py +++ b/prowler/providers/azure/services/postgresql/postgresql_service.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from typing import Optional +from azure.core.exceptions import ResourceNotFoundError from azure.mgmt.postgresqlflexibleservers import PostgreSQLManagementClient from prowler.lib.logger import logger @@ -18,65 +19,78 @@ class PostgreSQL(AzureService): flexible_servers = {} for subscription, client in self.clients.items(): try: + flexible_servers_list = self.list_with_rg_scope( + subscription, + client.servers.list, + client.servers.list_by_resource_group, + ) + flexible_servers.update({subscription: []}) - flexible_servers_list = client.servers.list() for postgresql_server in flexible_servers_list: - resource_group = self._get_resource_group(postgresql_server.id) - # Fetch full server object once to extract multiple properties - server_details = client.servers.get( - resource_group, postgresql_server.name - ) - require_secure_transport = self._get_require_secure_transport( - subscription, resource_group, postgresql_server.name - ) - active_directory_auth = self._extract_active_directory_auth( - server_details - ) - entra_id_admins = self._get_entra_id_admins( - subscription, resource_group, postgresql_server.name - ) - log_checkpoints = self._get_log_checkpoints( - subscription, resource_group, postgresql_server.name - ) - log_disconnections = self._get_log_disconnections( - subscription, resource_group, postgresql_server.name - ) - log_connections = self._get_log_connections( - subscription, resource_group, postgresql_server.name - ) - connection_throttling = self._get_connection_throttling( - subscription, resource_group, postgresql_server.name - ) - log_retention_days = self._get_log_retention_days( - subscription, resource_group, postgresql_server.name - ) - firewall = self._get_firewall( - subscription, resource_group, postgresql_server.name - ) - location = server_details.location - backup = getattr(server_details, "backup", None) - ha = getattr(server_details, "high_availability", None) - flexible_servers[subscription].append( - Server( - id=postgresql_server.id, - name=postgresql_server.name, - resource_group=resource_group, - location=location, - require_secure_transport=require_secure_transport, - active_directory_auth=active_directory_auth, - entra_id_admins=entra_id_admins, - log_checkpoints=log_checkpoints, - log_connections=log_connections, - log_disconnections=log_disconnections, - connection_throttling=connection_throttling, - log_retention_days=log_retention_days, - firewall=firewall, - geo_redundant_backup=getattr( - backup, "geo_redundant_backup", None - ), - high_availability_mode=getattr(ha, "mode", None), + # Isolate each server: a failure collecting one server must + # not abort collection of the remaining servers in the + # subscription. + try: + resource_group = self._get_resource_group(postgresql_server.id) + # Fetch full server object once to extract multiple properties + server_details = client.servers.get( + resource_group, postgresql_server.name + ) + require_secure_transport = self._get_require_secure_transport( + subscription, resource_group, postgresql_server.name + ) + active_directory_auth = self._extract_active_directory_auth( + server_details + ) + entra_id_admins = self._get_entra_id_admins( + subscription, resource_group, postgresql_server.name + ) + log_checkpoints = self._get_log_checkpoints( + subscription, resource_group, postgresql_server.name + ) + log_disconnections = self._get_log_disconnections( + subscription, resource_group, postgresql_server.name + ) + log_connections = self._get_log_connections( + subscription, resource_group, postgresql_server.name + ) + connection_throttling = self._get_connection_throttling( + subscription, resource_group, postgresql_server.name + ) + log_retention_days = self._get_log_retention_days( + subscription, resource_group, postgresql_server.name + ) + firewall = self._get_firewall( + subscription, resource_group, postgresql_server.name + ) + location = server_details.location + backup = getattr(server_details, "backup", None) + ha = getattr(server_details, "high_availability", None) + flexible_servers[subscription].append( + Server( + id=postgresql_server.id, + name=postgresql_server.name, + resource_group=resource_group, + location=location, + require_secure_transport=require_secure_transport, + active_directory_auth=active_directory_auth, + entra_id_admins=entra_id_admins, + log_checkpoints=log_checkpoints, + log_connections=log_connections, + log_disconnections=log_disconnections, + connection_throttling=connection_throttling, + log_retention_days=log_retention_days, + firewall=firewall, + geo_redundant_backup=getattr( + backup, "geo_redundant_backup", None + ), + high_availability_mode=getattr(ha, "mode", None), + ) + ) + except Exception as error: + logger.error( + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - ) except Exception as error: logger.error( f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -166,26 +180,43 @@ class PostgreSQL(AzureService): logger.error(f"Error getting Entra ID admins for {server_name}: {e}") return [] - def _get_connection_throttling(self, subscription, resouce_group_name, server_name): + def _get_connection_throttling( + self, subscription: str, resouce_group_name: str, server_name: str + ) -> Optional[str]: + """Get the ``connection_throttle.enable`` setting for a flexible server. + + The ``connection_throttle.enable`` server parameter was removed in + PostgreSQL 16+, so it no longer exists on newer flexible servers. When + the parameter is genuinely absent the Azure SDK raises + ``ResourceNotFoundError`` (error code ``ConfigurationNotExists``); that + case is treated as "not enabled" and ``None`` is returned so collection + of the server can continue. + + Any other error (permissions, throttling, transient SDK failures) is + intentionally left to propagate: returning ``None`` for those would make + the downstream check report the server as having connection throttling + disabled, silently turning a collection failure into a security finding. + + Args: + subscription: Azure subscription identifier. + resouce_group_name: Resource group containing the server. + server_name: PostgreSQL flexible server name. + + Returns: + The uppercased throttling value, or ``None`` when the parameter does + not exist on the server. + + Raises: + ResourceNotFoundError is handled; any other exception propagates to + the caller so it can be surfaced as a collection failure. + """ client = self.clients[subscription] try: connection_throttling = client.configurations.get( resouce_group_name, server_name, "connection_throttle.enable" ) return connection_throttling.value.upper() - except Exception as error: - message = str(error).lower() - if "connection_throttle.enable" in message and ( - "not exist" in message or "not found" in message - ): - # The "connection_throttle.enable" parameter does not exist on - # newer PostgreSQL versions (e.g. v18); this is expected. - return None - # Any other failure is a genuine problem: surface it, but still - # degrade gracefully instead of aborting the subscription inventory. - logger.error( - f"Error getting connection throttling for {server_name}: {error}" - ) + except ResourceNotFoundError: return None def _get_log_retention_days(self, subscription, resouce_group_name, server_name): diff --git a/prowler/providers/azure/services/recovery/recovery_service.py b/prowler/providers/azure/services/recovery/recovery_service.py index 38645219bd..6c414ee446 100644 --- a/prowler/providers/azure/services/recovery/recovery_service.py +++ b/prowler/providers/azure/services/recovery/recovery_service.py @@ -56,9 +56,14 @@ class Recovery(AzureService): try: vaults_dict: dict[str, dict[str, BackupVault]] = {} for subscription_id, client in self.clients.items(): - vaults = client.vaults.list_by_subscription_id() + vaults_list = self.list_with_rg_scope( + subscription_id, + client.vaults.list_by_subscription_id, + client.vaults.list_by_resource_group, + ) + vaults_dict[subscription_id] = {} - for vault in vaults: + for vault in vaults_list: vault_obj = BackupVault( id=vault.id, name=vault.name, diff --git a/prowler/providers/azure/services/sqlserver/sqlserver_service.py b/prowler/providers/azure/services/sqlserver/sqlserver_service.py index af02dace0d..274025fd7b 100644 --- a/prowler/providers/azure/services/sqlserver/sqlserver_service.py +++ b/prowler/providers/azure/services/sqlserver/sqlserver_service.py @@ -18,8 +18,13 @@ class SQLServer(AzureService): sql_servers = {} for subscription, client in self.clients.items(): try: + sql_servers_list = self.list_with_rg_scope( + subscription, + client.servers.list, + client.servers.list_by_resource_group, + ) + sql_servers.update({subscription: []}) - sql_servers_list = client.servers.list() for sql_server in sql_servers_list: resource_group = self._get_resource_group(sql_server.id) auditing_policies = self._get_server_blob_auditing_policies( diff --git a/prowler/providers/azure/services/storage/storage_service.py b/prowler/providers/azure/services/storage/storage_service.py index 74b8b3da30..863b33256e 100644 --- a/prowler/providers/azure/services/storage/storage_service.py +++ b/prowler/providers/azure/services/storage/storage_service.py @@ -20,8 +20,13 @@ class Storage(AzureService): storage_accounts = {} for subscription, client in self.clients.items(): try: + storage_accounts_list = self.list_with_rg_scope( + subscription, + client.storage_accounts.list, + client.storage_accounts.list_by_resource_group, + ) + storage_accounts.update({subscription: []}) - storage_accounts_list = client.storage_accounts.list() for storage_account in storage_accounts_list: parts = storage_account.id.split("/") if "resourceGroups" in parts: diff --git a/prowler/providers/azure/services/vm/vm_service.py b/prowler/providers/azure/services/vm/vm_service.py index b20f4b5678..8ef27c57f4 100644 --- a/prowler/providers/azure/services/vm/vm_service.py +++ b/prowler/providers/azure/services/vm/vm_service.py @@ -22,8 +22,12 @@ class VirtualMachines(AzureService): for subscription_id, client in self.clients.items(): try: - virtual_machines_list = client.virtual_machines.list_all() virtual_machines.update({subscription_id: {}}) + virtual_machines_list = self.list_with_rg_scope( + subscription_id, + client.virtual_machines.list_all, + client.virtual_machines.list, + ) for vm in virtual_machines_list: storage_profile = getattr(vm, "storage_profile", None) @@ -155,8 +159,12 @@ class VirtualMachines(AzureService): for subscription_id, client in self.clients.items(): try: - disks_list = client.disks.list() disks.update({subscription_id: {}}) + disks_list = self.list_with_rg_scope( + subscription_id, + client.disks.list, + client.disks.list_by_resource_group, + ) for disk in disks_list: vms_attached = [] @@ -202,9 +210,13 @@ class VirtualMachines(AzureService): vm_scale_sets = {} for subscription_id, client in self.clients.items(): try: - scale_sets = client.virtual_machine_scale_sets.list_all() vm_scale_sets[subscription_id] = {} - for scale_set in scale_sets: + scale_sets_list = self.list_with_rg_scope( + subscription_id, + client.virtual_machine_scale_sets.list_all, + client.virtual_machine_scale_sets.list, + ) + for scale_set in scale_sets_list: backend_pools = [] nic_configs = [] virtual_machine_profile = getattr( diff --git a/prowler/providers/cloudflare/cloudflare_provider.py b/prowler/providers/cloudflare/cloudflare_provider.py index 48763df395..9c39839f5d 100644 --- a/prowler/providers/cloudflare/cloudflare_provider.py +++ b/prowler/providers/cloudflare/cloudflare_provider.py @@ -46,6 +46,7 @@ class CloudflareProvider(Provider): """Cloudflare provider.""" _type: str = "cloudflare" + sdk_only: bool = False _session: CloudflareSession _identity: CloudflareIdentityInfo _audit_config: dict diff --git a/prowler/providers/common/models.py b/prowler/providers/common/models.py index 120cc1a374..84e71c4809 100644 --- a/prowler/providers/common/models.py +++ b/prowler/providers/common/models.py @@ -49,6 +49,16 @@ class ProviderOutputOptions: if updated_audit_config: provider._audit_config = updated_audit_config + # Secrets validation: --scan-secrets-validate opts into live validation + # of discovered secrets. Set the audit_config key directly so it applies + # even for providers whose default config does not declare it. + self.scan_secrets_validate = getattr(arguments, "scan_secrets_validate", False) + if self.scan_secrets_validate: + provider = Provider.get_global_provider() + audit_config = provider.audit_config or {} + audit_config["secrets_validate"] = True + provider._audit_config = audit_config + # Check output directory, if it is not created -> create it if self.output_directory and not self.fixer: if not isdir(self.output_directory): diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 9c314b3233..981d11fe00 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -142,6 +142,10 @@ class Provider(ABC): _cli_help_text: str = "" + # CLI/SDK-only provider, hidden from the app (API/UI). Defaults True; a + # provider opts into the app with ``sdk_only = False``. See get_app_providers(). + sdk_only: bool = True + @classmethod def from_cli_args(cls, arguments: Namespace, fixer_config: dict) -> "Provider": """Instantiate the provider from CLI arguments and return the instance. @@ -209,6 +213,65 @@ class Provider(ABC): f"{self.__class__.__name__} has not implemented get_mutelist_finding_args()" ) + @classmethod + def get_scan_arguments( + cls, + provider_uid: str, + secret: dict, + mutelist_content: Optional[dict] = None, + ) -> dict: + """Build the provider constructor kwargs from a stored uid and secret. + + This is the programmatic construction interface intended for callers + that will persist a provider as a single ``uid`` plus a ``secret`` dict + (e.g. the API), as opposed to the CLI which passes explicit per-provider + flags. + + The base implementation is a default: it passes the secret through, adds + the mutelist, and intentionally drops ``provider_uid``. The API consumes + this contract for external providers, so an external provider whose uid + is part of the scan scope (e.g. a subscription or project id) or that + renames/filters secret keys overrides this to inject the uid into the + right kwarg; until it does, the base default is not the final shape for + that provider. Built-in providers whose scope derives from the uid are + mapped on the API side and do not go through this method. + """ + kwargs = {**secret} + if mutelist_content is not None: + kwargs["mutelist_content"] = mutelist_content + return kwargs + + @classmethod + def get_connection_arguments(cls, provider_uid: str, secret: dict) -> dict: + """Build the ``test_connection`` kwargs from a stored uid and secret. + + Companion to :meth:`get_scan_arguments` for the connection check, which + often needs a different shape than the constructor. The base passes the + secret through and intentionally drops ``provider_uid``. An external + provider whose uid is part of the scope overrides this to add its + identity kwarg (and ``provider_id`` where its ``test_connection`` + expects it); built-in providers are mapped on the API side and do not go + through this method. + """ + return {**secret} + + @classmethod + def get_credentials_schema(cls) -> dict: + """Return the provider's credential schemas keyed by secret type. + + Maps each secret type the provider accepts (``"static"``, ``"role"`` or + ``"service_account"``) to the pydantic model that validates a secret of + that type. The provider declares which type each schema belongs to, so + the API validates a secret against the model for the secret type it is + created with and the chosen type stays bound to the shape it claims. + + Each model documents each field via ``Field(description=...)`` and + whether it is required (no default) or optional. An empty dict means no + schema is declared: the secret is accepted as an object and validated by + :meth:`test_connection`. + """ + return {} + def display_compliance_table( self, _findings: list, @@ -344,6 +407,7 @@ class Provider(ABC): tenant_id=arguments.tenant_id, region=arguments.azure_region, subscription_ids=arguments.subscription_id, + resource_groups=arguments.resource_groups, config_path=arguments.config_file, mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, @@ -570,6 +634,12 @@ class Provider(ABC): arguments, "okta_private_key_file", "" ), okta_scopes=getattr(arguments, "okta_scopes", None), + okta_retries_max_attempts=getattr( + arguments, "okta_retries_max_attempts", None + ), + okta_requests_per_second=getattr( + arguments, "okta_requests_per_second", None + ), config_path=arguments.config_file, mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, @@ -637,6 +707,28 @@ class Provider(ABC): providers.add(ep.name) return sorted(providers) + @staticmethod + def get_app_providers() -> list[str]: + """Return the providers the app (API/UI) may expose: those with + ``sdk_only = False``. + + Counterpart of :meth:`get_available_providers`, which lists every + provider for the CLI. A provider whose class cannot be imported is + treated as ``sdk_only`` (excluded) so a broken plug-in never leaks in. + """ + app_providers = [] + for name in Provider.get_available_providers(): + try: + provider_class = Provider.get_class(name) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + continue + if not getattr(provider_class, "sdk_only", True): + app_providers.append(name) + return app_providers + @staticmethod def is_tool_wrapper_provider(provider: str) -> bool: """Return True if the provider delegates scanning to an external tool. diff --git a/prowler/providers/gcp/gcp_provider.py b/prowler/providers/gcp/gcp_provider.py index 39b3392fdf..69ab9404ef 100644 --- a/prowler/providers/gcp/gcp_provider.py +++ b/prowler/providers/gcp/gcp_provider.py @@ -61,6 +61,7 @@ class GcpProvider(Provider): """ _type: str = "gcp" + sdk_only: bool = False _session: Credentials _project_ids: list _excluded_project_ids: list diff --git a/prowler/providers/github/github_provider.py b/prowler/providers/github/github_provider.py index 0f6e7f59ea..d832a93f98 100644 --- a/prowler/providers/github/github_provider.py +++ b/prowler/providers/github/github_provider.py @@ -91,6 +91,7 @@ class GithubProvider(Provider): """ _type: str = "github" + sdk_only: bool = False _auth_method: str = None MAX_REPO_LIST_LINES: int = 10_000 MAX_REPO_NAME_LENGTH: int = 500 diff --git a/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py b/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py index bf615a21b6..1f05035723 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py +++ b/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py @@ -28,6 +28,8 @@ class repository_default_branch_deletion_disabled(Check): report.status_extended = ( f"Repository {repo.name} does allow default branch deletion." ) + if repo.default_branch.branch_deletion_source == "ruleset_not_active": + report.status_extended = f"Repository {repo.name} has default branch deletion disabled in a ruleset, but the ruleset is not active." if not repo.default_branch.branch_deletion: report.status = "PASS" diff --git a/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.metadata.json index f774f204d0..2ef741d253 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.metadata.json @@ -9,12 +9,13 @@ "Severity": "high", "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "**GitHub repository default branch** blocks **force pushes** through branch protection.\n\nEvaluates whether the default branch permits force pushes.", + "Description": "**GitHub repository default branch** blocks **force pushes** through branch protection.\n\nEvaluates whether the default branch permits force pushes. This protection can be enforced through classic **branch protection** or repository **rulesets**.", "Risk": "Allowing **force pushes on the default branch** erodes **integrity** and **auditability** by enabling history rewrites and deletion of commits. Attackers or insiders can inject unreviewed code, bypass reviews and status checks, and corrupt PRs, risking supply-chain compromise and reduced **availability**.", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule", - "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#allow-force-pushes" + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#allow-force-pushes", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" ], "Remediation": { "Code": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py b/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py index 16eb7fa794..de61256080 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py +++ b/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py @@ -26,6 +26,11 @@ class repository_default_branch_disallows_force_push(Check): report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = f"Repository {repo.name} does allow force pushes on default branch ({repo.default_branch.name})." + if ( + repo.default_branch.allow_force_pushes_source + == "ruleset_not_active" + ): + report.status_extended = f"Repository {repo.name} has force pushes disallowed in a ruleset on default branch ({repo.default_branch.name}), but the ruleset is not active." if not repo.default_branch.allow_force_pushes: report.status = "PASS" diff --git a/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.metadata.json index 70668adbc1..75c84dc752 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.metadata.json @@ -9,12 +9,13 @@ "Severity": "high", "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "**GitHub repository default branch** applies **branch protection rules** to **administrators** via `enforce_admins`, holding admin pushes to the same requirements as other contributors (reviews, status checks, and push restrictions).", + "Description": "**GitHub repository default branch** applies **branch protection rules** to **administrators** via `enforce_admins`, holding admin pushes to the same requirements as other contributors (reviews, status checks, and push restrictions). This protection can be enforced through classic **branch protection** or repository **rulesets**.", "Risk": "Without admin enforcement, privileged users can bypass reviews and checks, enabling **unauthorized code changes**. A compromised admin token can inject backdoors, alter dependencies, or disable safeguards, undermining **integrity**, exposing secrets (**confidentiality**), and causing outages (**availability**).", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule", - "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#do-not-allow-bypassing-the-above-settings" + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#do-not-allow-bypassing-the-above-settings", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" ], "Remediation": { "Code": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py b/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py index 0a70cf0aea..142f0a5b7b 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py +++ b/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py @@ -26,6 +26,8 @@ class repository_default_branch_protection_applies_to_admins(Check): report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = f"Repository {repo.name} does not enforce administrators to be subject to the same branch protection rules as other users." + if repo.default_branch.enforce_admins_source == "ruleset_not_active": + report.status_extended = f"Repository {repo.name} has a ruleset that would apply to administrators, but the ruleset is not active." if repo.default_branch.enforce_admins: report.status = "PASS" diff --git a/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.metadata.json index 8f173e868f..dbefae6179 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.metadata.json @@ -9,12 +9,13 @@ "Severity": "high", "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "**GitHub repository default branch** has **branch protection rules** enabled to restrict direct changes and require reviewed, validated merges. The evaluation determines whether the default branch enforces such rules.", + "Description": "**GitHub repository default branch** has **branch protection rules** enabled to restrict direct changes and require reviewed, validated merges. The evaluation determines whether the default branch enforces such rules. This protection can be enforced through classic **branch protection** or repository **rulesets**.", "Risk": "Without default-branch protection, changes can bypass reviews and checks, enabling:\n- Unauthorized direct pushes/force pushes\n- Malicious code injection and workflow tampering\n- Accidental deletions or unstable releases\nThis undermines code **integrity** and service **availability**.", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule", - "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches" + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" ], "Remediation": { "Code": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py b/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py index 1348018ee1..b305f5019a 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py +++ b/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py @@ -26,6 +26,8 @@ class repository_default_branch_protection_enabled(Check): report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = f"Repository {repo.name} does not enforce branch protection on default branch ({repo.default_branch.name})." + if repo.default_branch.protected_source == "ruleset_not_active": + report.status_extended = f"Repository {repo.name} has a ruleset configured on default branch ({repo.default_branch.name}), but the ruleset is not active." if repo.default_branch.protected: report.status = "PASS" diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json index 465e61aad9..53f849271a 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json @@ -9,12 +9,13 @@ "Severity": "high", "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "**GitHub repository default branch** requires **Code Owners** approval for pull requests that modify paths declared in `CODEOWNERS`", + "Description": "**GitHub repository default branch** requires **Code Owners** approval for pull requests that modify paths declared in `CODEOWNERS`. This protection can be enforced through classic **branch protection** or repository **rulesets**.", "Risk": "Without required **Code Owners** review, non-owners can merge changes to sensitive code, undermining **integrity**.\nThis increases the chance of **malicious code injection**, hidden backdoors, or fragile changes that enable **data exfiltration** or cause outages, impacting confidentiality and availability.", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-review-from-code-owners", - "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule" + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" ], "Remediation": { "Code": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py index 4c5b75010a..3b8a749961 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py @@ -30,6 +30,11 @@ class repository_default_branch_requires_codeowners_review(Check): else: report.status = "FAIL" report.status_extended = f"Repository {repo.name} does not require code owner approval for changes to owned code." + if ( + repo.default_branch.require_code_owner_reviews_source + == "ruleset_not_active" + ): + report.status_extended = f"Repository {repo.name} has code owner approval configured in a ruleset, but the ruleset is not active." findings.append(report) diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.metadata.json index beaff24afb..c304ef26c1 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.metadata.json @@ -9,12 +9,13 @@ "Severity": "medium", "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "**GitHub repository default branch** uses **branch protection** to require **conversation resolution** on pull requests (`Require conversation resolution before merging`).", + "Description": "**GitHub repository default branch** uses **branch protection** to require **conversation resolution** on pull requests (`Require conversation resolution before merging`). This protection can be enforced through classic **branch protection** or repository **rulesets**.", "Risk": "Unresolved threads let code with known concerns reach default, weakening **integrity** and **confidentiality**. Insecure changes or secrets may ship, enabling injection, auth bypass, or data exposure. **Availability** can suffer from regressions; review accountability is reduced.", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule", - "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches" + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" ], "Remediation": { "Code": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py b/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py index da33754cf5..9c25f8a14f 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py @@ -26,6 +26,11 @@ class repository_default_branch_requires_conversation_resolution(Check): report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = f"Repository {repo.name} does not require conversation resolution on default branch ({repo.default_branch.name})." + if ( + repo.default_branch.conversation_resolution_source + == "ruleset_not_active" + ): + report.status_extended = f"Repository {repo.name} has conversation resolution configured in a ruleset on default branch ({repo.default_branch.name}), but the ruleset is not active." if repo.default_branch.conversation_resolution: report.status = "PASS" diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.metadata.json index 215ab496aa..5ecb27ffc6 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.metadata.json @@ -9,12 +9,13 @@ "Severity": "low", "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "**GitHub repository default branch** enforces `Require linear history`, blocking merge commits and allowing only `squash` or `rebase` merges", + "Description": "**GitHub repository default branch** enforces `Require linear history`, blocking merge commits and allowing only `squash` or `rebase` merges. This protection can be enforced through classic **branch protection** or repository **rulesets**.", "Risk": "Without a **linear history**, commit provenance is harder to verify, weakening **integrity** and **accountability**.\n\nMerge commits can obscure diffs entering the default branch, hindering audits and rollbacks, enabling unnoticed **malicious or unreviewed code**, and delaying incident response.", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-linear-history", - "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule" + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" ], "Remediation": { "Code": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py b/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py index 29a0e51b51..86f944738c 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py @@ -26,6 +26,11 @@ class repository_default_branch_requires_linear_history(Check): report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = f"Repository {repo.name} does not require linear history on default branch ({repo.default_branch.name})." + if ( + repo.default_branch.required_linear_history_source + == "ruleset_not_active" + ): + report.status_extended = f"Repository {repo.name} has linear history configured in a ruleset on default branch ({repo.default_branch.name}), but the ruleset is not active." if repo.default_branch.required_linear_history: report.status = "PASS" diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.metadata.json index dda4f25ea0..c7f07b1101 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.metadata.json @@ -9,12 +9,13 @@ "Severity": "medium", "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "**GitHub repository default branch** enforces **required reviews** with a minimum of `2` approving reviews before a pull request can be merged.\n\nAssesses whether an approval threshold of at least `2` is configured for code changes targeting the default branch.", + "Description": "**GitHub repository default branch** enforces **required reviews** with a minimum of `2` approving reviews before a pull request can be merged.\n\nAssesses whether an approval threshold of at least `2` is configured for code changes targeting the default branch. This protection can be enforced through classic **branch protection** or repository **rulesets**.", "Risk": "Without multi-review approval on the default branch, a single actor can merge changes, degrading **integrity** and **accountability**. This enables:\n- supply-chain tampering or backdoors\n- introduction of exploitable bugs\n- bypass of change control via compromised accounts", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/approving-a-pull-request-with-required-reviews", - "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-pull-request-reviews-before-merging" + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-pull-request-reviews-before-merging", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" ], "Remediation": { "Code": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py b/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py index 6312b98d02..5ddf37e853 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py @@ -26,6 +26,8 @@ class repository_default_branch_requires_multiple_approvals(Check): report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = f"Repository {repo.name} does not enforce at least 2 approvals for code changes." + if repo.default_branch.approval_count_source == "ruleset_not_active": + report.status_extended = f"Repository {repo.name} has at least 2 approvals configured in a ruleset, but the ruleset is not active." if repo.default_branch.approval_count >= 2: report.status = "PASS" diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.metadata.json index d3d9c91806..9b045fc4e9 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.metadata.json @@ -9,12 +9,13 @@ "Severity": "high", "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "**GitHub repository default branch** enforces **signed and verified commits** (`Require signed commits`), allowing only commits with valid cryptographic signatures to be pushed or merged.", + "Description": "**GitHub repository default branch** enforces **signed and verified commits** (`Require signed commits`), allowing only commits with valid cryptographic signatures to be pushed or merged. This protection can be enforced through classic **branch protection** or repository **rulesets**.", "Risk": "Without required signing, commit authorship can be spoofed and unverified changes added, impacting integrity.\n- Backdoor injection\n- History tampering and forged identities\n- Release pipeline abuse and supply-chain compromise", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-signed-commits", - "https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification" + "https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" ], "Remediation": { "Code": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.py b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.py index 4b6aa3ae4c..110a7d4a1e 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.py @@ -26,6 +26,11 @@ class repository_default_branch_requires_signed_commits(Check): report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = f"Repository {repo.name} does not require signed commits on default branch ({repo.default_branch.name})." + if ( + repo.default_branch.require_signed_commits_source + == "ruleset_not_active" + ): + report.status_extended = f"Repository {repo.name} has signed commits configured in a ruleset on default branch ({repo.default_branch.name}), but the ruleset is not active." if repo.default_branch.require_signed_commits: report.status = "PASS" diff --git a/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.metadata.json index 6d450fde33..d0799962ce 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.metadata.json @@ -9,13 +9,14 @@ "Severity": "high", "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "**GitHub repository default branch** uses **required status checks**, indicating whether merges are gated by successful check results on pull requests", + "Description": "**GitHub repository default branch** uses **required status checks**, indicating whether merges are gated by successful check results on pull requests. This protection can be enforced through classic **branch protection** or repository **rulesets**.", "Risk": "Without required checks, unvetted commits can be merged, degrading code **integrity** and **availability**. Skipped or failing validations may introduce vulnerable dependencies, break builds, or allow malicious code, enabling supply-chain compromise and rapid propagation to production.", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule", "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-status-checks-before-merging", - "https://docs.gearset.com/en/articles/2437757-managing-status-check-rules-in-github" + "https://docs.gearset.com/en/articles/2437757-managing-status-check-rules-in-github", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" ], "Remediation": { "Code": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py b/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py index e67b9def2c..f96d5b058d 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py +++ b/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py @@ -28,6 +28,8 @@ class repository_default_branch_status_checks_required(Check): report.status_extended = ( f"Repository {repo.name} does not enforce status checks." ) + if repo.default_branch.status_checks_source == "ruleset_not_active": + report.status_extended = f"Repository {repo.name} has status checks configured in a ruleset, but the ruleset is not active." if repo.default_branch.status_checks: report.status = "PASS" diff --git a/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.py b/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.py index 7120e0411a..2ece6abf42 100644 --- a/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.py +++ b/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.py @@ -22,6 +22,9 @@ class repository_has_codeowners_file(Check): """ findings = [] for repo in repository_client.repositories.values(): + if repo.archived: + continue + if repo.codeowners_exists is not None: report = CheckReportGithub(metadata=self.metadata(), resource=repo) if repo.codeowners_exists: diff --git a/prowler/providers/github/services/repository/repository_service.py b/prowler/providers/github/services/repository/repository_service.py index 0201d199e4..fd8aa28662 100644 --- a/prowler/providers/github/services/repository/repository_service.py +++ b/prowler/providers/github/services/repository/repository_service.py @@ -202,15 +202,40 @@ class Repository(GithubService): return None - def _get_dismiss_stale_reviews_from_rulesets( + def _evaluate_default_branch_rulesets( self, repo, default_branch: str - ) -> tuple[Optional[bool], Optional[str]]: - """Evaluate dismiss-stale-review coverage from repository and parent rulesets.""" - rulesets = self._get_repository_rulesets(repo) - if rulesets is None: - return None, None + ) -> dict[str, tuple[Optional[int], str]]: + """Evaluate default-branch protection coverage provided by rulesets. - has_inactive_ruleset = False + Fetches the repository (and parent) rulesets once and walks every rule that + targets the default branch, mapping each ruleset rule to its equivalent classic + branch-protection attribute. + + Returns: + dict mapping a ``Branch`` attribute name to a ``(value, source)`` tuple where + ``source`` is ``"ruleset"`` (enforced by an active ruleset), ``"ruleset_not_active"`` + (configured by a ruleset that is not active) or absent when no ruleset addresses + the attribute. ``value`` is only meaningful for ``approval_count`` (the enforced + review count); for boolean attributes it is ``None`` and the caller derives the + value from ``source`` and the attribute polarity. + """ + result: dict[str, tuple[Optional[int], str]] = {} + rulesets = self._get_repository_rulesets(repo) + if not rulesets: + return result + + active: set[str] = set() + inactive: set[str] = set() + active_approval_count: Optional[int] = None + inactive_approval_count: Optional[int] = None + any_active_ruleset = False + any_inactive_ruleset = False + # A ruleset with no bypass actors applies to everyone, including administrators + # (the rulesets equivalent of "enforce admins"). A ruleset that has bypass actors + # would not apply to admins even if activated, so it must not drive the + # enforce-admins finding in either the active or the inactive case. + admins_enforced_active = False + admins_configured_inactive = False for ruleset in rulesets: if ruleset.get("target") != "branch": @@ -219,26 +244,119 @@ class Repository(GithubService): if not self._ruleset_targets_default_branch(ruleset, default_branch): continue + enforcement = ruleset.get("enforcement") + is_active = enforcement in {"active", "enabled"} + is_inactive = enforcement in {"disabled", "evaluate"} + if not (is_active or is_inactive): + continue + + has_no_bypass_actors = not (ruleset.get("bypass_actors") or []) + if is_active: + any_active_ruleset = True + if has_no_bypass_actors: + admins_enforced_active = True + else: + any_inactive_ruleset = True + if has_no_bypass_actors: + admins_configured_inactive = True + + bucket = active if is_active else inactive + for rule in ruleset.get("rules") or []: - if rule.get("type") != "pull_request": - continue + rule_type = rule.get("type") + params = rule.get("parameters") or {} - dismiss_stale_reviews = (rule.get("parameters") or {}).get( - "dismiss_stale_reviews_on_push" - ) - if dismiss_stale_reviews is not True: - continue + if rule_type == "required_linear_history": + bucket.add("required_linear_history") + elif rule_type == "required_signatures": + bucket.add("require_signed_commits") + elif rule_type == "required_status_checks": + # Only enforced when at least one status check is configured; + # an empty list (or just strict policy) requires nothing. + if params.get("required_status_checks"): + bucket.add("status_checks") + elif rule_type == "non_fast_forward": + # Presence of the rule disallows force pushes. + bucket.add("allow_force_pushes") + elif rule_type == "deletion": + # Presence of the rule disallows branch deletion. + bucket.add("branch_deletion") + elif rule_type == "pull_request": + bucket.add("require_pull_request") + if params.get("require_code_owner_review") is True: + bucket.add("require_code_owner_reviews") + if params.get("required_review_thread_resolution") is True: + bucket.add("conversation_resolution") + if params.get("dismiss_stale_reviews_on_push") is True: + bucket.add("dismiss_stale_reviews") + count = params.get("required_approving_review_count") + if isinstance(count, int): + if is_active: + active_approval_count = max( + active_approval_count or 0, count + ) + else: + inactive_approval_count = max( + inactive_approval_count or 0, count + ) - enforcement = ruleset.get("enforcement") - if enforcement in {"active", "enabled"}: - return True, "ruleset" - if enforcement in {"disabled", "evaluate"}: - has_inactive_ruleset = True + for concept in ( + "required_linear_history", + "require_signed_commits", + "status_checks", + "allow_force_pushes", + "branch_deletion", + "require_pull_request", + "require_code_owner_reviews", + "conversation_resolution", + "dismiss_stale_reviews", + ): + if concept in active: + result[concept] = (None, "ruleset") + elif concept in inactive: + result[concept] = (None, "ruleset_not_active") - if has_inactive_ruleset: - return False, "ruleset_not_active" + if active_approval_count is not None: + result["approval_count"] = (active_approval_count, "ruleset") + elif inactive_approval_count is not None: + result["approval_count"] = (inactive_approval_count, "ruleset_not_active") - return None, None + if any_active_ruleset: + result["protected"] = (None, "ruleset") + elif any_inactive_ruleset: + result["protected"] = (None, "ruleset_not_active") + + if admins_enforced_active: + result["enforce_admins"] = (None, "ruleset") + elif admins_configured_inactive: + result["enforce_admins"] = (None, "ruleset_not_active") + + return result + + @staticmethod + def _merge_ruleset_bool( + classic_value: Optional[bool], ruleset_source: Optional[str], good: bool = True + ) -> tuple[Optional[bool], Optional[str]]: + """Merge a boolean branch-protection attribute with its ruleset evaluation. + + Args: + classic_value: The value resolved from classic branch protection. + ruleset_source: ``"ruleset"``, ``"ruleset_not_active"`` or ``None``. + good: The compliant value for the attribute (``True`` for positive attributes, + ``False`` for inverted ones such as ``allow_force_pushes``). + + Returns: + A ``(value, source)`` tuple. Classic protection wins when it already satisfies + the control; otherwise an active ruleset enforces the compliant value, and an + inactive ruleset surfaces the non-compliant value with a ``"ruleset_not_active"`` + source so checks can explain the gap. + """ + classic_pass = classic_value == good + if ruleset_source == "ruleset": + return good, "ruleset" + if ruleset_source == "ruleset_not_active" and not classic_pass: + return (not good), "ruleset_not_active" + return classic_value, ("classic" if classic_pass else None) def _list_repositories(self): """ @@ -398,6 +516,17 @@ class Repository(GithubService): conversation_resolution = False dismiss_stale_reviews = False dismiss_stale_reviews_source = None + protected_source = None + require_pull_request_source = None + approval_count_source = None + required_linear_history_source = None + allow_force_pushes_source = None + branch_deletion_source = None + require_code_owner_reviews_source = None + require_signed_commits_source = None + status_checks_source = None + enforce_admins_source = None + conversation_resolution_source = None try: branch = repo.get_branch(default_branch) if branch.protected: @@ -458,20 +587,98 @@ class Repository(GithubService): f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - if dismiss_stale_reviews is not None: + # Branch protection enforced through rulesets is equivalent to classic branch + # protection, so merge any ruleset coverage before reporting findings to avoid + # false positives for repositories that have migrated to rulesets. + if branch_protection is not None: + ruleset_eval = self._evaluate_default_branch_rulesets( + repo, default_branch + ) + + branch_protection, protected_source = self._merge_ruleset_bool( + branch_protection, ruleset_eval.get("protected", (None, None))[1] + ) + require_pr, require_pull_request_source = self._merge_ruleset_bool( + require_pr, + ruleset_eval.get("require_pull_request", (None, None))[1], + ) ( - ruleset_dismiss_stale_reviews, - ruleset_source, - ) = self._get_dismiss_stale_reviews_from_rulesets(repo, default_branch) - if ruleset_dismiss_stale_reviews: + required_linear_history, + required_linear_history_source, + ) = self._merge_ruleset_bool( + required_linear_history, + ruleset_eval.get("required_linear_history", (None, None))[1], + ) + ( + require_signed_commits, + require_signed_commits_source, + ) = self._merge_ruleset_bool( + require_signed_commits, + ruleset_eval.get("require_signed_commits", (None, None))[1], + ) + status_checks, status_checks_source = self._merge_ruleset_bool( + status_checks, ruleset_eval.get("status_checks", (None, None))[1] + ) + ( + require_code_owner_reviews, + require_code_owner_reviews_source, + ) = self._merge_ruleset_bool( + require_code_owner_reviews, + ruleset_eval.get("require_code_owner_reviews", (None, None))[1], + ) + ( + conversation_resolution, + conversation_resolution_source, + ) = self._merge_ruleset_bool( + conversation_resolution, + ruleset_eval.get("conversation_resolution", (None, None))[1], + ) + enforce_admins, enforce_admins_source = self._merge_ruleset_bool( + enforce_admins, + ruleset_eval.get("enforce_admins", (None, None))[1], + ) + allow_force_pushes, allow_force_pushes_source = ( + self._merge_ruleset_bool( + allow_force_pushes, + ruleset_eval.get("allow_force_pushes", (None, None))[1], + good=False, + ) + ) + branch_deletion, branch_deletion_source = self._merge_ruleset_bool( + branch_deletion, + ruleset_eval.get("branch_deletion", (None, None))[1], + good=False, + ) + + # Dismiss stale reviews keeps its dedicated handling so the classic source + # set above (when enabled) is preserved. + _, dismiss_source = ruleset_eval.get( + "dismiss_stale_reviews", (None, None) + ) + if dismiss_source == "ruleset": dismiss_stale_reviews = True dismiss_stale_reviews_source = "ruleset" elif ( - ruleset_source == "ruleset_not_active" and not dismiss_stale_reviews + dismiss_source == "ruleset_not_active" and not dismiss_stale_reviews ): dismiss_stale_reviews = False dismiss_stale_reviews_source = "ruleset_not_active" + # Approval count takes the strongest requirement between classic and rulesets. + approval_value, approval_source = ruleset_eval.get( + "approval_count", (None, None) + ) + if approval_source == "ruleset" and approval_value is not None: + if approval_value > approval_cnt: + approval_cnt = approval_value + approval_count_source = "ruleset" + elif ( + approval_source == "ruleset_not_active" + and (approval_value or 0) >= 2 + and approval_cnt < 2 + ): + approval_count_source = "ruleset_not_active" + secret_scanning_enabled = False dependabot_alerts_enabled = False try: @@ -517,17 +724,28 @@ class Repository(GithubService): default_branch=Branch( name=default_branch, protected=branch_protection, + protected_source=protected_source, default_branch=True, require_pull_request=require_pr, + require_pull_request_source=require_pull_request_source, approval_count=approval_cnt, + approval_count_source=approval_count_source, required_linear_history=required_linear_history, + required_linear_history_source=required_linear_history_source, allow_force_pushes=allow_force_pushes, + allow_force_pushes_source=allow_force_pushes_source, branch_deletion=branch_deletion, + branch_deletion_source=branch_deletion_source, status_checks=status_checks, + status_checks_source=status_checks_source, enforce_admins=enforce_admins, + enforce_admins_source=enforce_admins_source, conversation_resolution=conversation_resolution, + conversation_resolution_source=conversation_resolution_source, require_code_owner_reviews=require_code_owner_reviews, + require_code_owner_reviews_source=require_code_owner_reviews_source, require_signed_commits=require_signed_commits, + require_signed_commits_source=require_signed_commits_source, dismiss_stale_reviews=dismiss_stale_reviews, dismiss_stale_reviews_source=dismiss_stale_reviews_source, ), @@ -599,17 +817,28 @@ class Branch(BaseModel): name: str protected: Optional[bool] + protected_source: Optional[str] = None default_branch: bool require_pull_request: Optional[bool] + require_pull_request_source: Optional[str] = None approval_count: Optional[int] + approval_count_source: Optional[str] = None required_linear_history: Optional[bool] + required_linear_history_source: Optional[str] = None allow_force_pushes: Optional[bool] + allow_force_pushes_source: Optional[str] = None branch_deletion: Optional[bool] + branch_deletion_source: Optional[str] = None status_checks: Optional[bool] + status_checks_source: Optional[str] = None enforce_admins: Optional[bool] + enforce_admins_source: Optional[str] = None require_code_owner_reviews: Optional[bool] + require_code_owner_reviews_source: Optional[str] = None require_signed_commits: Optional[bool] + require_signed_commits_source: Optional[str] = None conversation_resolution: Optional[bool] + conversation_resolution_source: Optional[str] = None dismiss_stale_reviews: Optional[bool] dismiss_stale_reviews_source: Optional[str] = None diff --git a/prowler/providers/googleworkspace/googleworkspace_provider.py b/prowler/providers/googleworkspace/googleworkspace_provider.py index 4c431aa736..2a40a59ddf 100644 --- a/prowler/providers/googleworkspace/googleworkspace_provider.py +++ b/prowler/providers/googleworkspace/googleworkspace_provider.py @@ -54,6 +54,7 @@ class GoogleworkspaceProvider(Provider): """ _type: str = "googleworkspace" + sdk_only: bool = False _session: GoogleWorkspaceSession _identity: GoogleWorkspaceIdentityInfo _domain_resource: GoogleWorkspaceResource diff --git a/prowler/providers/iac/iac_provider.py b/prowler/providers/iac/iac_provider.py index b91fdf070e..df9114e033 100644 --- a/prowler/providers/iac/iac_provider.py +++ b/prowler/providers/iac/iac_provider.py @@ -28,6 +28,7 @@ from prowler.providers.common.provider import Provider class IacProvider(Provider): _type: str = "iac" + sdk_only: bool = False audit_metadata: Audit_Metadata def __init__( diff --git a/prowler/providers/image/image_provider.py b/prowler/providers/image/image_provider.py index b142240867..7a245e4125 100644 --- a/prowler/providers/image/image_provider.py +++ b/prowler/providers/image/image_provider.py @@ -59,6 +59,7 @@ class ImageProvider(Provider): """ _type: str = "image" + sdk_only: bool = False FINDING_BATCH_SIZE: int = 100 MAX_IMAGE_LIST_LINES: int = 10_000 MAX_IMAGE_NAME_LENGTH: int = 500 diff --git a/prowler/providers/kubernetes/kubernetes_provider.py b/prowler/providers/kubernetes/kubernetes_provider.py index 2572b5be88..b350a18ebc 100644 --- a/prowler/providers/kubernetes/kubernetes_provider.py +++ b/prowler/providers/kubernetes/kubernetes_provider.py @@ -58,6 +58,7 @@ class KubernetesProvider(Provider): """ _type: str = "kubernetes" + sdk_only: bool = False _session: KubernetesSession _namespaces: list _audit_config: dict diff --git a/prowler/providers/m365/lib/powershell/m365_powershell.py b/prowler/providers/m365/lib/powershell/m365_powershell.py index 4dae094d90..d9cbdcee90 100644 --- a/prowler/providers/m365/lib/powershell/m365_powershell.py +++ b/prowler/providers/m365/lib/powershell/m365_powershell.py @@ -1002,6 +1002,31 @@ class M365PowerShell(PowerShellSession): json_parse=True, ) + def get_application_access_policies(self) -> dict: + """ + Get Exchange Online Application Access Policies. + + Retrieves all Exchange Online Application Access Policies. + + Returns: + dict: Application access policies in JSON format. + + Example: + >>> get_application_access_policies() + [ + { + "Identity": "Policy1", + "AppId": "12345678-1234-1234-1234-123456789012", + "AccessRight": "RestrictAccess", + "Description": "Restrict mailbox access" + } + ] + """ + return self.execute( + "Get-ApplicationAccessPolicy | ConvertTo-Json -Depth 10", + json_parse=True, + ) + def get_user_account_status(self) -> dict: """ Get User Account Status. diff --git a/prowler/providers/m365/m365_provider.py b/prowler/providers/m365/m365_provider.py index 0454d85f3d..040e390658 100644 --- a/prowler/providers/m365/m365_provider.py +++ b/prowler/providers/m365/m365_provider.py @@ -99,6 +99,7 @@ class M365Provider(Provider): """ _type: str = "m365" + sdk_only: bool = False _session: DefaultAzureCredential # Must be used besides being named for Azure _identity: M365IdentityInfo _audit_config: dict diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/entra_conditional_access_policy_groups_management_restricted.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/entra_conditional_access_policy_groups_management_restricted.metadata.json new file mode 100644 index 0000000000..ccfa9eb086 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/entra_conditional_access_policy_groups_management_restricted.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_groups_management_restricted", + "CheckTitle": "Conditional Access policy groups are management-restricted or role-assignable", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra **Conditional Access** policies are evaluated for security groups referenced in `includeGroups` and `excludeGroups`. Referenced groups must be protected by **Restricted Management Administrative Unit** membership or configured as **role-assignable groups**.", + "Risk": "Groups referenced by **Conditional Access** policies become privileged identity control points. If their membership can be changed by broad group administrators or applications, an attacker may bypass access controls by adding themselves to an excluded group or removing themselves from an included group, weakening **confidentiality** and **integrity** without modifying the policy.", + "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/group?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/admin-units-restricted-management", + "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/groups-concept" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Microsoft Entra admin center at https://entra.microsoft.com.\n2. Review every group used by enabled or report-only Conditional Access policies under Users > Include and Users > Exclude.\n3. For each group, either place it in a Restricted Management Administrative Unit or recreate/use a role-assignable group where appropriate.\n4. Restrict group ownership and membership management to privileged administrators.\n5. Remove stale or deleted group references from Conditional Access policies.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Protect every security group that scopes **Conditional Access** decisions with **Restricted Management Administrative Units** or **role-assignable group** controls. Regularly audit group membership, owners, and stale Conditional Access references.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_groups_management_restricted" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Conditional Access policies require Microsoft Entra ID P1 or P2 licenses. The check relies on Microsoft Graph group properties `isManagementRestricted` and `isAssignableToRole`, which must be requested with `$select`." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/entra_conditional_access_policy_groups_management_restricted.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/entra_conditional_access_policy_groups_management_restricted.py new file mode 100644 index 0000000000..884f065257 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/entra_conditional_access_policy_groups_management_restricted.py @@ -0,0 +1,130 @@ +from collections import defaultdict + +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 ( + ConditionalAccessPolicyState, +) + + +class entra_conditional_access_policy_groups_management_restricted(Check): + """Ensure Conditional Access group scopes are protected against broad management. + + Security groups referenced by enabled or report-only Conditional Access + policies (in ``includeGroups`` or ``excludeGroups``) are privileged control + points: anyone able to change their membership can silently bypass or weaken + a policy. This check reports one finding per referenced group. + + - PASS: The group is management-restricted or role-assignable, or no enabled + or report-only policy references any group. + - FAIL: The group is neither management-restricted nor role-assignable. + - MANUAL: The group reference no longer resolves in Microsoft Entra ID and + must be verified or removed. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the check logic. + + Returns: + A list of reports, one per group referenced by an enabled or + report-only Conditional Access policy. + """ + findings = [] + + group_usage = defaultdict(lambda: {"include": [], "exclude": []}) + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + user_conditions = policy.conditions.user_conditions + if not user_conditions: + continue + + for group_id in user_conditions.included_groups: + group_usage[group_id]["include"].append(policy) + for group_id in user_conditions.excluded_groups: + group_usage[group_id]["exclude"].append(policy) + + if not group_usage: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "PASS" + report.status_extended = ( + "No enabled or report-only Conditional Access Policy references " + "groups." + ) + findings.append(report) + return findings + + groups_by_id = {group.id: group for group in entra_client.groups} + + for group_id in sorted(group_usage): + usage = self._policy_usage(group_usage[group_id]) + group = groups_by_id.get(group_id) + + if not group: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name=group_id, + resource_id=group_id, + ) + report.status = "MANUAL" + report.status_extended = ( + f"Group {group_id} referenced by Conditional Access Policies " + f"could not be resolved in Microsoft Entra ID; verify the group " + f"exists or remove the stale reference ({usage})." + ) + findings.append(report) + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=group, + resource_name=group.name, + resource_id=group.id, + ) + + if group.is_management_restricted or group.is_assignable_to_role: + report.status = "PASS" + report.status_extended = ( + f"Group {group.name} ({group.id}) referenced by Conditional " + f"Access Policies is management-restricted or role-assignable." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Group {group.name} ({group.id}) referenced by Conditional " + f"Access Policies is neither management-restricted nor " + f"role-assignable ({usage})." + ) + + findings.append(report) + + return findings + + @staticmethod + def _policy_usage(usage) -> str: + """Render the include/exclude policy usage of a group as a string. + + Args: + usage: Mapping with ``include`` and ``exclude`` lists of policies. + + Returns: + A string such as ``"include policies: A; exclude policies: B"``. + """ + + def policy_names(policies): + if not policies: + return "none" + return ", ".join(sorted({policy.display_name for policy in policies})) + + return ( + f"include policies: {policy_names(usage['include'])}; " + f"exclude policies: {policy_names(usage['exclude'])}" + ) diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/entra_conditional_access_policy_no_exclusion_gaps.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/entra_conditional_access_policy_no_exclusion_gaps.metadata.json new file mode 100644 index 0000000000..35c80b8054 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/entra_conditional_access_policy_no_exclusion_gaps.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_no_exclusion_gaps", + "CheckTitle": "Conditional Access exclusions are covered by another policy (no exclusion gaps)", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Verifies that every object excluded from an enabled Microsoft Entra **Conditional Access** policy (users, groups, roles, or applications) is still included by at least one enabled policy, so the exclusion keeps a compensating control. The Directory Synchronization Accounts role and confirmed emergency access (break glass) accounts are treated as intentional and not reported.", + "Risk": "An object excluded from a Conditional Access policy but never included by any other enabled policy sits completely outside Conditional Access enforcement. This creates a silent **MFA bypass** and **lateral movement** path: a principal exempted as a one-off remains permanently uncontrolled if no compensating policy covers it.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/plan-conditional-access", + "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/entra/identity/role-based-access-control/security-emergency-access" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to Protection > Conditional Access > Policies in the Microsoft Entra admin center.\n2. For each object reported as an exclusion gap, decide whether the exclusion is still required.\n3. If the exclusion must stay, add the object to the Include scope of another enabled Conditional Access policy that enforces compensating controls (for example MFA).\n4. If the exclusion is no longer required, remove it so the object falls back under the original policy.\n5. Re-run the check to confirm no exclusion gaps remain.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Ensure every object excluded from a Conditional Access policy is included by at least one other enabled policy that applies compensating controls. Reserve exclusions for break-glass accounts and the Directory Synchronization Accounts role, and review exclusion lists regularly so that exempted principals never drift outside Conditional Access enforcement.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_no_exclusion_gaps" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_conditional_access_policy_directory_sync_account_excluded", + "entra_emergency_access_exclusion" + ], + "Notes": "Covers user, group, role, and application exclusions. Platform and location exclusions are out of scope because they are scoping conditions rather than principals removed from enforcement. Service-principal exclusions require additional fields on the ConditionalAccessPolicy service model." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/entra_conditional_access_policy_no_exclusion_gaps.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/entra_conditional_access_policy_no_exclusion_gaps.py new file mode 100644 index 0000000000..0974581759 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/entra_conditional_access_policy_no_exclusion_gaps.py @@ -0,0 +1,262 @@ +"""Check that Conditional Access exclusions do not create coverage gaps.""" + +from collections import Counter, defaultdict + +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 ( + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, +) + +# Directory Synchronization Accounts built-in role template ID. Prowler enforces +# excluding this role (see entra_conditional_access_policy_directory_sync_account_excluded); +# it is intended to have no fallback, so it never counts as a gap here. +DIRECTORY_SYNC_ROLE_TEMPLATE_ID = "d29b2b05-8046-44ba-8758-1e26182fcf32" + + +class entra_conditional_access_policy_no_exclusion_gaps(Check): + """Check that objects excluded from Conditional Access policies remain covered. + + Excluding a principal from a Conditional Access (CA) policy is only safe when + that principal is still covered by *some* enabled CA policy that enforces + compensating controls. An object excluded everywhere and included nowhere + sits completely outside CA enforcement, which is how MFA bypass and lateral + movement against admin accounts happen in real incidents. + + For every enabled CA policy this check walks each exclusion collection and + verifies the excluded object is still in scope of another enabled policy: one + that includes it (explicitly, or via the "All" wildcard) and does not itself + exclude it. A wildcard belonging to the policy that excludes the object does + not count, so a one-off exclusion with no compensating policy is reported as + a gap. + + Only principals and target apps are evaluated (users, groups, roles, + applications). Platform and location exclusions are scoping conditions rather + than principals removed from enforcement, so they are out of scope. + + - PASS: Every excluded object stays in scope of another enabled policy, or no + enabled policy uses any exclusion. + - FAIL: At least one excluded object is in scope of no other enabled policy. + """ + + # (label, conditions attribute, included attr, excluded attr, wildcard token). + # The wildcard token, when present in an include collection, scopes a policy + # to every object of that type. Groups and roles have no wildcard: they are + # always explicit identifiers and transitive group/role expansion is out of + # scope for v1, so an excluded group/role is only "covered" when the same + # identifier is explicitly included by another enabled policy. + _COLLECTIONS = [ + ("users", "user_conditions", "included_users", "excluded_users", "All"), + ("groups", "user_conditions", "included_groups", "excluded_groups", None), + ("roles", "user_conditions", "included_roles", "excluded_roles", None), + ( + "applications", + "application_conditions", + "included_applications", + "excluded_applications", + "All", + ), + ] + + def execute(self) -> list[CheckReportM365]: + """Execute the Conditional Access exclusion-gap check. + + Returns: + list[CheckReportM365]: A single-element list with the aggregate result. + """ + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + + enabled_policies = [ + policy + for policy in entra_client.conditional_access_policies.values() + if policy.state == ConditionalAccessPolicyState.ENABLED + ] + + if not enabled_policies: + report.status = "PASS" + report.status_extended = ( + "No enabled Conditional Access policies found; " + "no exclusion coverage gaps are possible." + ) + return [report] + + emergency_users, emergency_groups = self._emergency_access_objects() + + # gaps: type label -> set of excluded object IDs with no compensating policy + gaps = defaultdict(set) + any_exclusion = False + + for policy in enabled_policies: + for ( + label, + conditions_attr, + included_attr, + excluded_attr, + wildcard, + ) in self._COLLECTIONS: + conditions = getattr(policy.conditions, conditions_attr) + if not conditions: + continue + for object_id in getattr(conditions, excluded_attr): + any_exclusion = True + if self._is_expected_exclusion( + label, object_id, emergency_users, emergency_groups + ): + continue + if not self._is_covered( + object_id, + conditions_attr, + included_attr, + excluded_attr, + wildcard, + enabled_policies, + ): + gaps[label].add(object_id) + + if not any_exclusion: + report.status = "PASS" + report.status_extended = ( + "No enabled Conditional Access policy uses exclusions; " + "no coverage gaps are possible." + ) + return [report] + + if not gaps: + report.status = "PASS" + report.status_extended = ( + "Every object excluded from an enabled Conditional Access policy is " + "still in scope of another enabled policy, so a compensating control " + "remains in effect." + ) + return [report] + + report.status = "FAIL" + report.status_extended = ( + "Conditional Access exclusion gaps found " + f"({self._format_gaps(gaps, self._build_name_index())}). These objects " + "are excluded but in scope of no other enabled policy, leaving them " + "outside CA enforcement." + ) + return [report] + + def _build_name_index(self) -> dict: + """Map excluded object IDs to display names per type, for readable findings. + + Users, groups, and applications resolve to their display name; roles have + no loaded name catalog, so role template IDs are shown as-is. Unresolved + IDs (for example deleted principals still referenced by a policy) fall + back to the raw identifier. + """ + users = { + uid: user.name + for uid, user in (getattr(entra_client, "users", {}) or {}).items() + if getattr(user, "name", None) + } + groups = { + group.id: group.name + for group in (getattr(entra_client, "groups", []) or []) + if getattr(group, "name", None) + } + applications = { + sp.app_id: sp.name + for sp in (getattr(entra_client, "service_principals", {}) or {}).values() + if getattr(sp, "app_id", None) and getattr(sp, "name", None) + } + return {"users": users, "groups": groups, "applications": applications} + + def _is_covered( + self, + object_id, + conditions_attr, + included_attr, + excluded_attr, + wildcard, + enabled_policies, + ) -> bool: + """Return True if any enabled policy keeps ``object_id`` in scope. + + A policy keeps the object in scope when it includes it —explicitly or via + the type's wildcard token— and does not also exclude it. The wildcard of a + policy that itself excludes the object does not count, which is what makes + a one-off exclusion with no compensating policy a real gap. + """ + for policy in enabled_policies: + conditions = getattr(policy.conditions, conditions_attr) + if not conditions: + continue + if object_id in getattr(conditions, excluded_attr): + continue + included = getattr(conditions, included_attr) + if object_id in included or (wildcard is not None and wildcard in included): + return True + return False + + def _emergency_access_objects(self) -> tuple[set, set]: + """Return user and group IDs that act as emergency access (break-glass). + + Objects excluded from *every* enabled (enforced) Conditional Access policy + with a Block grant control are intended, compensating gaps and must not be + reported here. Only ENABLED policies count: report-only policies are not + enforced, so including them would dilute the "excluded everywhere" check + and could hide a genuine break-glass account (consistent with execute()). + """ + blocking_policies = [ + policy + for policy in entra_client.conditional_access_policies.values() + if policy.state == ConditionalAccessPolicyState.ENABLED + and ConditionalAccessGrantControl.BLOCK + in policy.grant_controls.built_in_controls + ] + if not blocking_policies: + return set(), set() + + total = len(blocking_policies) + excluded_users = Counter() + excluded_groups = Counter() + for policy in blocking_policies: + user_conditions = policy.conditions.user_conditions + if not user_conditions: + continue + for user_id in user_conditions.excluded_users: + excluded_users[user_id] += 1 + for group_id in user_conditions.excluded_groups: + excluded_groups[group_id] += 1 + + emergency_users = {uid for uid, n in excluded_users.items() if n == total} + emergency_groups = {gid for gid, n in excluded_groups.items() if n == total} + return emergency_users, emergency_groups + + def _is_expected_exclusion( + self, label, object_id, emergency_users, emergency_groups + ) -> bool: + """Exclusions that are intentional by design and must not count as gaps.""" + if label == "roles" and object_id == DIRECTORY_SYNC_ROLE_TEMPLATE_ID: + return True + if label == "users" and object_id in emergency_users: + return True + if label == "groups" and object_id in emergency_groups: + return True + return False + + def _format_gaps(self, gaps, name_index) -> str: + """Render the orphaned objects grouped by type, by display name when known. + + Each ID is shown as its display name when resolvable; unresolved IDs (and + all roles, which have no name catalog) fall back to the raw identifier. + """ + parts = [] + for label in ("users", "groups", "roles", "applications"): + if label not in gaps: + continue + names = name_index.get(label, {}) + rendered = sorted( + names.get(object_id, object_id) for object_id in gaps[label] + ) + parts.append(f"{label}: {', '.join(rendered)}") + return " | ".join(parts) diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index bbaa80edcc..a083eb1c3c 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -1,4 +1,5 @@ import asyncio +import importlib import json from asyncio import gather from datetime import datetime, timezone @@ -7,10 +8,8 @@ from typing import Any, Dict, List, Optional, Set, Tuple from uuid import UUID from kiota_abstractions.base_request_configuration import RequestConfiguration +from msgraph.generated.groups.groups_request_builder import GroupsRequestBuilder from msgraph.generated.models.o_data_errors.o_data_error import ODataError -from msgraph.generated.security.microsoft_graph_security_run_hunting_query.run_hunting_query_post_request_body import ( - RunHuntingQueryPostRequestBody, -) from msgraph.generated.users.users_request_builder import UsersRequestBuilder from pydantic.v1 import BaseModel, validator @@ -18,6 +17,10 @@ from prowler.lib.logger import logger from prowler.providers.m365.lib.service.service import M365Service from prowler.providers.m365.m365_provider import M365Provider +run_hunting_query_body = importlib.import_module( + "msgraph.generated.security.microsoft_graph_security_run_hunting_query." + "run_hunting_query_post_request_body" +) # 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 @@ -83,6 +86,7 @@ class Entra(M365Service): self.tenant_domain = provider.identity.tenant_domain self.tenant_id = getattr(provider.identity, "tenant_id", None) self.user_registration_details_error: Optional[str] = None + self.exchange_mailbox_permission_service_principals_error: Optional[str] = None attributes = loop.run_until_complete( gather( self._get_authorization_policy(), @@ -97,6 +101,7 @@ class Entra(M365Service): self._get_authentication_method_configurations(), self._get_service_principals(), self._get_app_registrations(), + self._get_exchange_mailbox_permission_service_principals(), ) ) @@ -114,6 +119,9 @@ class Entra(M365Service): ] = attributes[9] self.service_principals: Dict[str, "ServicePrincipal"] = attributes[10] self.app_registrations: Dict[str, "AppRegistration"] = attributes[11] + self.exchange_mailbox_permission_service_principals: Dict[ + str, "ServicePrincipal" + ] = attributes[12] self.user_accounts_status = {} # Resolve directory-object identifiers referenced by Conditional Access @@ -743,16 +751,48 @@ class Entra(M365Service): logger.info("Entra - Getting groups...") groups = [] try: - groups_data = await self.client.groups.get() - for group in groups_data.value: - groups.append( - Group( - id=group.id, - name=group.display_name, - groupTypes=group.group_types, - membershipRule=group.membership_rule, - ) + query_parameters = ( + GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters( + select=[ + "id", + "displayName", + "groupTypes", + "membershipRule", + "isAssignableToRole", + "isManagementRestricted", + ], ) + ) + request_configuration = RequestConfiguration( + query_parameters=query_parameters, + ) + groups_data = await self.client.groups.get( + request_configuration=request_configuration, + ) + + while groups_data: + for group in groups_data.value: + groups.append( + Group( + id=group.id, + name=group.display_name, + groupTypes=group.group_types or [], + membershipRule=group.membership_rule, + is_assignable_to_role=getattr( + group, "is_assignable_to_role", False + ) + or False, + is_management_restricted=getattr( + group, "is_management_restricted", False + ) + or False, + ) + ) + + next_link = getattr(groups_data, "odata_next_link", None) + if not next_link: + break + groups_data = await self.client.groups.with_url(next_link).get() except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -1021,7 +1061,9 @@ OAuthAppInfo | project OAuthAppId, AppName, AppStatus, PrivilegeLevel, Permissions, ServicePrincipalId, IsAdminConsented, LastUsedTime, AppOrigin """ - request_body = RunHuntingQueryPostRequestBody(query=query) + request_body = run_hunting_query_body.RunHuntingQueryPostRequestBody( + query=query + ) result = await self.client.security.microsoft_graph_security_run_hunting_query.post( request_body @@ -1349,6 +1391,112 @@ OAuthAppInfo ) return service_principals + async def _get_exchange_mailbox_permission_service_principals(self): + """Retrieve service principals with Exchange mailbox Graph app roles.""" + logger.info( + "Entra - Getting service principals with Exchange mailbox permissions..." + ) + self.exchange_mailbox_permission_service_principals_error = None + service_principals = {} + graph_service_principal = None + candidate_service_principals = [] + + try: + sp_response = await self.client.service_principals.get() + while sp_response: + for sp in getattr(sp_response, "value", []) or []: + app_id = getattr(sp, "app_id", None) + if app_id == MICROSOFT_GRAPH_APP_ID: + graph_service_principal = sp + continue + + if not getattr(sp, "account_enabled", True): + continue + + raw_owner = getattr(sp, "app_owner_organization_id", None) + app_owner_org_id = str(raw_owner).lower() if raw_owner else None + if app_owner_org_id in MICROSOFT_FIRST_PARTY_TENANT_IDS: + continue + + candidate_service_principals.append(sp) + + next_link = getattr(sp_response, "odata_next_link", None) + if not next_link: + break + sp_response = await self.client.service_principals.with_url( + next_link + ).get() + + if graph_service_principal is None: + return service_principals + + graph_service_principal_id = getattr(graph_service_principal, "id", None) + exchange_app_roles = {} + for role in getattr(graph_service_principal, "app_roles", []) or []: + role_value = getattr(role, "value", "") or "" + allowed_member_types = getattr(role, "allowed_member_types", []) or [] + if ( + role_value in EXCHANGE_MAILBOX_GRAPH_PERMISSIONS + and "Application" in allowed_member_types + ): + exchange_app_roles[str(getattr(role, "id", ""))] = role_value + + if not graph_service_principal_id or not exchange_app_roles: + return service_principals + + for sp in candidate_service_principals: + assignments_response = ( + await self.client.service_principals.by_service_principal_id( + sp.id + ).app_role_assignments.get() + ) + exchange_permissions = set() + + while assignments_response: + for assignment in getattr(assignments_response, "value", []) or []: + resource_id = str(getattr(assignment, "resource_id", "")) + app_role_id = str(getattr(assignment, "app_role_id", "")) + if resource_id == graph_service_principal_id: + permission = exchange_app_roles.get(app_role_id) + if permission: + exchange_permissions.add(permission) + + next_link = getattr(assignments_response, "odata_next_link", None) + if not next_link: + break + assignments_response = ( + await self.client.service_principals.by_service_principal_id( + sp.id + ) + .app_role_assignments.with_url(next_link) + .get() + ) + + if exchange_permissions: + raw_owner = getattr(sp, "app_owner_organization_id", None) + service_principals[sp.id] = ServicePrincipal( + id=sp.id, + name=getattr(sp, "display_name", "") or "", + app_id=getattr(sp, "app_id", "") or "", + app_owner_organization_id=( + str(raw_owner).lower() if raw_owner else None + ), + account_enabled=getattr(sp, "account_enabled", True), + service_principal_type=getattr( + sp, "service_principal_type", "Application" + ), + exchange_mailbox_permissions=sorted(exchange_permissions), + ) + except Exception as error: + self.exchange_mailbox_permission_service_principals_error = ( + f"{error.__class__.__name__}: {error}" + ) + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return service_principals + async def _get_app_registrations(self) -> Dict[str, "AppRegistration"]: """Retrieve application registrations from Microsoft Entra. @@ -1818,6 +1966,8 @@ class Group(BaseModel): name: str groupTypes: List[str] membershipRule: Optional[str] + is_assignable_to_role: bool = False + is_management_restricted: bool = False class AdminConsentPolicy(BaseModel): @@ -2029,6 +2179,27 @@ TIER_0_ROLE_TEMPLATE_IDS = { "e00e864a-17c5-4a4b-9c06-f5b95a8d5bd8", # Partner Tier2 Support } +MICROSOFT_GRAPH_APP_ID = "00000003-0000-0000-c000-000000000000" + +MICROSOFT_FIRST_PARTY_TENANT_IDS = { + "72f988bf-86f1-41af-91ab-2d7cd011db47", + "f8cdef31-a31e-4b4a-93e4-5f571e91255a", +} + +EXCHANGE_MAILBOX_GRAPH_PERMISSIONS = { + "Calendars.Read", + "Calendars.ReadWrite", + "Contacts.Read", + "Contacts.ReadWrite", + "Mail.Read", + "Mail.ReadBasic", + "Mail.ReadBasic.All", + "Mail.ReadWrite", + "Mail.Send", + "MailboxSettings.Read", + "MailboxSettings.ReadWrite", +} + class ServicePrincipal(BaseModel): """Model representing a Microsoft Entra ID service principal. @@ -2061,6 +2232,9 @@ class ServicePrincipal(BaseModel): password_credentials: List[PasswordCredential] = [] key_credentials: List[KeyCredential] = [] directory_role_template_ids: List[str] = [] + account_enabled: bool = True + service_principal_type: str = "Application" + exchange_mailbox_permissions: List[str] = [] sp_owner_ids: List[str] = [] app_owner_ids: List[str] = [] diff --git a/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/__init__.py b/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps.metadata.json b/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps.metadata.json new file mode 100644 index 0000000000..0e4da9420d --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "m365", + "CheckID": "exchange_application_access_policy_restricts_mailbox_apps", + "CheckTitle": "Apps with Exchange mailbox permissions must be scoped via Application Access Policy", + "CheckType": [], + "ServiceName": "exchange", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "**Microsoft 365 Exchange Online** applications with mailbox access permissions should be restricted using **Application Access Policies**.\n\nThis check evaluates whether applications granted Exchange-related Microsoft Graph application permissions are scoped using Exchange Online `ApplicationAccessPolicy` objects.", + "Risk": "Applications with unrestricted Exchange mailbox permissions may gain tenant-wide mailbox access. Without **Application Access Policies**, compromised or over-privileged applications can read or manipulate mail across all mailboxes, leading to unauthorized access, data exfiltration, phishing, and loss of confidentiality and integrity.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/auth-limit-mailbox-access", + "https://learn.microsoft.com/en-us/powershell/module/exchange/new-applicationaccesspolicy", + "https://learn.microsoft.com/en-us/powershell/module/exchange/get-applicationaccesspolicy", + "https://learn.microsoft.com/en-us/graph/api/resources/serviceprincipal?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/serviceprincipal-list-approleassignments?view=graph-rest-1.0" + ], + "Remediation": { + "Code": { + "CLI": "New-ApplicationAccessPolicy -AppId -PolicyScopeGroupId -AccessRight RestrictAccess -Description \"Restrict mailbox access\"", + "NativeIaC": "", + "Other": "1. Connect to Exchange Online PowerShell\n2. Run Get-ApplicationAccessPolicy to review existing policies\n3. Identify applications with Exchange-related Graph application permissions\n4. Create an Application Access Policy for each required application\n5. Scope mailbox access using a dedicated mail-enabled security group", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict applications with Exchange mailbox permissions using **Application Access Policies**. Apply least privilege by limiting mailbox scope to only required users or groups. Regularly review app permissions and remove unused or excessive mailbox access.", + "Url": "https://hub.prowler.com/check/exchange_application_access_policy_restricts_mailbox_apps" + } + }, + "Categories": [ + "identity-access", + "email-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps.py b/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps.py new file mode 100644 index 0000000000..d4c786b338 --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps.py @@ -0,0 +1,99 @@ +import importlib +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + +exchange_client = importlib.import_module( + "prowler.providers.m365.services.exchange.exchange_client" +).exchange_client + + +class exchange_application_access_policy_restricts_mailbox_apps(Check): + """ + Check if applications with Exchange mailbox permissions + are restricted using Exchange Application Access Policies. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + + application_access_policies = exchange_client.application_access_policies + if application_access_policies is None: + report = CheckReportM365( + metadata=self.metadata(), + resource=exchange_client.organization_config, + resource_name="Exchange Online", + resource_id="ExchangeOnlineTenant", + ) + report.status = "MANUAL" + report.status_extended = ( + "Exchange Online PowerShell is unavailable. " + "Enable Exchange Online PowerShell credentials to evaluate " + "Application Access Policies." + ) + findings.append(report) + return findings + + mailbox_permission_collection_error = getattr( + entra_client, + "exchange_mailbox_permission_service_principals_error", + None, + ) + if isinstance(mailbox_permission_collection_error, str): + report = CheckReportM365( + metadata=self.metadata(), + resource=exchange_client.organization_config, + resource_name="Exchange Online", + resource_id="ExchangeOnlineTenant", + ) + report.status = "MANUAL" + report.status_extended = ( + "Microsoft Graph mailbox permission collection failed. " + "Manually verify whether applications with Exchange mailbox " + "permissions are restricted using Application Access Policies." + ) + findings.append(report) + return findings + + policy_app_ids = { + policy.app_id.lower() + for policy in application_access_policies + if getattr(policy, "app_id", None) + and getattr(policy, "access_right", None) == "RestrictAccess" + } + + service_principals = ( + entra_client.exchange_mailbox_permission_service_principals.values() + ) + for service_principal in service_principals: + report = CheckReportM365( + metadata=self.metadata(), + resource=service_principal, + resource_name=service_principal.name, + resource_id=service_principal.id, + ) + permissions = ", ".join(service_principal.exchange_mailbox_permissions) + + if service_principal.app_id.lower() in policy_app_ids: + report.status = "PASS" + report.status_extended = ( + f"Service principal '{service_principal.name}' " + f"({service_principal.app_id}) " + "has Exchange mailbox permissions " + f"({permissions}) and is restricted using an Application " + "Access Policy." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Service principal '{service_principal.name}' " + f"({service_principal.app_id}) " + "has Exchange mailbox permissions " + f"({permissions}) but is not restricted using an Application " + "Access Policy." + ) + + 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 3ce6528aa9..c4e4b309a4 100644 --- a/prowler/providers/m365/services/exchange/exchange_service.py +++ b/prowler/providers/m365/services/exchange/exchange_service.py @@ -43,6 +43,7 @@ class Exchange(M365Service): self.role_assignment_policies = [] self.mailbox_audit_properties = [] self.shared_mailboxes = [] + self.application_access_policies = None self.mailboxes = None if self.powershell: @@ -56,6 +57,9 @@ 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.application_access_policies = ( + self._get_application_access_policies() + ) self.mailboxes = self._get_mailboxes() self.powershell.close() @@ -366,6 +370,53 @@ class Exchange(M365Service): ) return shared_mailboxes + def _get_application_access_policies(self): + """ + Get Exchange Online Application Access Policies. + + Returns: + Optional[list[ApplicationAccessPolicy]]: List of application access + policies, or None if the PowerShell command failed. + """ + logger.info("Microsoft365 - Getting application access policies...") + + application_access_policies = [] + + try: + policies_data = self.powershell.get_application_access_policies() + + if not policies_data: + return application_access_policies + + if isinstance(policies_data, dict): + policies_data = [policies_data] + + for policy in policies_data: + if policy: + application_access_policies.append( + ApplicationAccessPolicy( + identity=policy.get("Identity", ""), + app_id=policy.get("AppId", ""), + access_right=policy.get( + "AccessRight", + "", + ), + description=policy.get( + "Description", + "", + ), + ) + ) + + except Exception as error: + logger.error( + f"{error.__class__.__name__}" + f"[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + + return application_access_policies + def _get_mailboxes(self) -> Optional[list["Mailbox"]]: """ Get all recipient-facing mailboxes from Exchange Online. @@ -554,6 +605,17 @@ class SharedMailbox(BaseModel): identity: str +class ApplicationAccessPolicy(BaseModel): + """ + Model for Exchange Online Application Access Policy. + """ + + identity: str + app_id: str + access_right: str + description: str + + class Mailbox(BaseModel): """ Model for an Exchange Online recipient-facing mailbox. diff --git a/prowler/providers/mongodbatlas/mongodbatlas_provider.py b/prowler/providers/mongodbatlas/mongodbatlas_provider.py index c40f1ced6e..07ff6f86cc 100644 --- a/prowler/providers/mongodbatlas/mongodbatlas_provider.py +++ b/prowler/providers/mongodbatlas/mongodbatlas_provider.py @@ -36,6 +36,7 @@ class MongodbatlasProvider(Provider): """ _type: str = "mongodbatlas" + sdk_only: bool = False _session: MongoDBAtlasSession _identity: MongoDBAtlasIdentityInfo _audit_config: dict diff --git a/prowler/providers/okta/lib/arguments/arguments.py b/prowler/providers/okta/lib/arguments/arguments.py index 4ed5cfa187..3865e30a4b 100644 --- a/prowler/providers/okta/lib/arguments/arguments.py +++ b/prowler/providers/okta/lib/arguments/arguments.py @@ -42,3 +42,25 @@ def init_parser(self): default=None, metavar="OKTA_SCOPES", ) + okta_rate_limit_subparser = okta_parser.add_argument_group("Rate limiting") + okta_rate_limit_subparser.add_argument( + "--okta-retries-max-attempts", + type=int, + default=None, + help=( + "Maximum number of retries on Okta API rate limiting (HTTP 429). " + "Overrides the config.yaml value (okta_max_retries). Default: 5." + ), + metavar="OKTA_RETRIES_MAX_ATTEMPTS", + ) + okta_rate_limit_subparser.add_argument( + "--okta-requests-per-second", + type=float, + default=None, + help=( + "Maximum aggregate Okta API requests per second. Throttles requests " + "to stay under Okta's rate limits. Overrides the config.yaml value " + "(okta_requests_per_second); set to 0 to disable. Default: 4." + ), + metavar="OKTA_REQUESTS_PER_SECOND", + ) diff --git a/prowler/providers/okta/lib/service/rate_limiter.py b/prowler/providers/okta/lib/service/rate_limiter.py new file mode 100644 index 0000000000..9a8c72fe63 --- /dev/null +++ b/prowler/providers/okta/lib/service/rate_limiter.py @@ -0,0 +1,105 @@ +"""Client-side request throttling for the Okta provider. + +The Okta SDK already retries on HTTP 429 (see `service.py`), but retrying is +reactive: it only helps *after* a rate limit has been hit, and each backoff +waits out a full reset window. To avoid hitting Okta's limits in the first +place, this module paces outbound requests with a shared token bucket. + +A single `OktaRateLimiter` instance lives on the provider and is shared by every +service's SDK client, so the cap applies to the *aggregate* request rate rather +than per client. The limiter is injected by wrapping the SDK's `HTTPClient` +(via the `httpClient` config key) and awaiting `acquire()` before each call. + +Note: Okta enforces rate limits per endpoint, so a single requests-per-second +cap is a deliberately simple, blunt control. It keeps bursty pagination from +overrunning the limits without trying to model every per-endpoint budget. +""" + +import asyncio +import threading +import time + +from okta.http_client import HTTPClient + +# Default aggregate request rate. Okta-managed orgs commonly throttle the +# busiest endpoints around a handful of requests per second, so we pace below +# that by default. Set `okta_requests_per_second` to 0 (or a negative value) to +# disable throttling entirely. +DEFAULT_REQUESTS_PER_SECOND = 4 + + +class OktaRateLimiter: + """Token-bucket limiter shared across a provider's Okta SDK clients. + + The bucket refills at `requests_per_second` tokens per second up to a small + burst capacity. `acquire()` consumes one token, sleeping just long enough + when the bucket is empty. Token accounting is wall-clock based + (`time.monotonic`) so it stays correct across the separate event loops the + services spin up with `asyncio.run`. + """ + + def __init__( + self, + requests_per_second: float, + *, + clock=time.monotonic, + sleep=asyncio.sleep, + ): + if requests_per_second <= 0: + raise ValueError("requests_per_second must be greater than 0") + self._rate = float(requests_per_second) + # Allow up to one second of requests to burst, then settle to the rate. + self._capacity = max(1.0, self._rate) + self._tokens = self._capacity + self._clock = clock + self._sleep = sleep + self._last = clock() + # Guards the token math only; never held across an await. + self._lock = threading.Lock() + + async def acquire(self) -> None: + """Block until a request token is available, then consume it.""" + while True: + with self._lock: + now = self._clock() + self._tokens = min( + self._capacity, self._tokens + (now - self._last) * self._rate + ) + self._last = now + if self._tokens >= 1: + self._tokens -= 1 + return + wait = (1 - self._tokens) / self._rate + await self._sleep(wait) + + +def build_throttled_http_client(limiter: OktaRateLimiter) -> type[HTTPClient]: + """Return an `HTTPClient` subclass that paces requests through `limiter`. + + The Okta SDK instantiates `config["httpClient"]` with its HTTP config, so we + return a class (not an instance) that closes over the shared limiter. + + Args: + limiter: Shared token-bucket limiter that paces the aggregate request + rate across every service client of the provider. + + Returns: + An `HTTPClient` subclass that awaits the limiter before each request. + """ + + class ThrottledHTTPClient(HTTPClient): + """`HTTPClient` that acquires a limiter token before each request.""" + + async def send_request(self, request): + """Acquire a rate-limit token, then delegate to the SDK client. + + Args: + request: The request payload built by the Okta SDK. + + Returns: + The result of the underlying `HTTPClient.send_request` call. + """ + await limiter.acquire() + return await super().send_request(request) + + return ThrottledHTTPClient diff --git a/prowler/providers/okta/lib/service/service.py b/prowler/providers/okta/lib/service/service.py index baaabcb219..0c6c24c186 100644 --- a/prowler/providers/okta/lib/service/service.py +++ b/prowler/providers/okta/lib/service/service.py @@ -3,11 +3,21 @@ from typing import TYPE_CHECKING from okta.client import Client as OktaSDKClient +from prowler.providers.okta.lib.service.rate_limiter import build_throttled_http_client from prowler.providers.okta.models import OktaSession if TYPE_CHECKING: from prowler.providers.okta.okta_provider import OktaProvider +# Okta API rate-limit handling. The okta-sdk-python `Client` already backs off +# on HTTP 429 by sleeping until the `X-Rate-Limit-Reset` window before retrying, +# but it only does so `maxRetries` times (SDK default 2). On busy orgs that is +# too few and requests fail with partial data, so we raise it. See config.yaml +# (`okta_max_retries` / `okta_request_timeout`) for the user-facing knobs and the +# rationale behind the 300s timeout default. +DEFAULT_MAX_RETRIES = 5 +DEFAULT_REQUEST_TIMEOUT = 300 + class OktaService: """Base class for Okta service implementations. @@ -20,13 +30,34 @@ class OktaService: def __init__(self, service: str, provider: "OktaProvider"): self.provider = provider self.service = service - self.client = self.__set_client__(provider.session) self.audit_config = provider.audit_config self.fixer_config = provider.fixer_config + self.client = self.__set_client__( + provider.session, self.audit_config, provider.rate_limiter + ) @staticmethod - def __set_client__(session: OktaSession) -> OktaSDKClient: - return OktaSDKClient(session.to_sdk_config()) + def __set_client__( + session: OktaSession, audit_config: dict, rate_limiter=None + ) -> OktaSDKClient: + # Start from the shared SDK config and layer the rate-limit settings on + # top. `Client(config)` deep-merges these flat keys onto its defaults, so + # `rateLimit`/`requestTimeout` override the SDK's built-in values. + config = session.to_sdk_config() + audit_config = audit_config or {} + config["rateLimit"] = { + "maxRetries": audit_config.get("okta_max_retries", DEFAULT_MAX_RETRIES) + } + config["requestTimeout"] = audit_config.get( + "okta_request_timeout", DEFAULT_REQUEST_TIMEOUT + ) + # Proactively pace outbound requests so scans stay under Okta's limits + # instead of relying on the 429 retry as a safety net. The limiter is + # shared across every service client of the provider, so the cap applies + # to the aggregate request rate. + if rate_limiter is not None: + config["httpClient"] = build_throttled_http_client(rate_limiter) + return OktaSDKClient(config) @staticmethod def _run(coro): diff --git a/prowler/providers/okta/okta_provider.py b/prowler/providers/okta/okta_provider.py index 8859507ec7..757e81d51d 100644 --- a/prowler/providers/okta/okta_provider.py +++ b/prowler/providers/okta/okta_provider.py @@ -30,6 +30,10 @@ from prowler.providers.okta.exceptions.exceptions import ( OktaSetUpSessionError, ) from prowler.providers.okta.lib.mutelist.mutelist import OktaMutelist +from prowler.providers.okta.lib.service.rate_limiter import ( + DEFAULT_REQUESTS_PER_SECOND, + OktaRateLimiter, +) from prowler.providers.okta.models import OktaIdentityInfo, OktaSession DEFAULT_SCOPES = [ @@ -78,12 +82,14 @@ class OktaProvider(Provider): """ _type: str = "okta" + sdk_only: bool = False _auth_method: str = None _session: OktaSession _identity: OktaIdentityInfo _audit_config: dict _fixer_config: dict _mutelist: Mutelist + _rate_limiter: Optional[OktaRateLimiter] audit_metadata: Audit_Metadata def __init__( @@ -93,6 +99,8 @@ class OktaProvider(Provider): okta_private_key: str = "", okta_private_key_file: str = "", okta_scopes: Optional[Union[str, list[str]]] = None, + okta_retries_max_attempts: int = None, + okta_requests_per_second: float = None, config_path: str = None, config_content: dict = None, fixer_config: dict = {}, @@ -124,6 +132,30 @@ class OktaProvider(Provider): if not config_path: config_path = default_config_file_path self._audit_config = load_and_validate_config_file(self._type, config_path) + + # CLI flags take precedence over config.yaml for the Okta API rate-limit + # settings. Services read these from audit_config when building the SDK + # client, so override the loaded values here. + if okta_retries_max_attempts is not None: + self._audit_config["okta_max_retries"] = okta_retries_max_attempts + logger.info(f"Okta max retries set to {okta_retries_max_attempts}") + if okta_requests_per_second is not None: + self._audit_config["okta_requests_per_second"] = okta_requests_per_second + + # Build the shared request limiter once, here, so every service client + # paces against the same token bucket. A value of 0 (or below) disables + # throttling. + requests_per_second = self._audit_config.get( + "okta_requests_per_second", DEFAULT_REQUESTS_PER_SECOND + ) + if requests_per_second and requests_per_second > 0: + self._rate_limiter = OktaRateLimiter(requests_per_second) + logger.info( + f"Okta request throttling enabled at {requests_per_second} req/s" + ) + else: + self._rate_limiter = None + self._fixer_config = fixer_config if mutelist_content: @@ -163,6 +195,10 @@ class OktaProvider(Provider): def mutelist(self) -> OktaMutelist: return self._mutelist + @property + def rate_limiter(self) -> Optional[OktaRateLimiter]: + return self._rate_limiter + @staticmethod def validate_arguments( okta_org_domain: str = "", diff --git a/prowler/providers/openstack/openstack_provider.py b/prowler/providers/openstack/openstack_provider.py index e81a399478..e11c4f7909 100644 --- a/prowler/providers/openstack/openstack_provider.py +++ b/prowler/providers/openstack/openstack_provider.py @@ -36,6 +36,7 @@ class OpenstackProvider(Provider): """OpenStack provider responsible for bootstrapping the SDK session.""" _type: str = "openstack" + sdk_only: bool = False _session: OpenStackSession _identity: OpenStackIdentityInfo _audit_config: dict diff --git a/prowler/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data.metadata.json b/prowler/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data.metadata.json index 597d3ab4d4..bd83049d82 100644 --- a/prowler/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data.metadata.json +++ b/prowler/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data.metadata.json @@ -36,5 +36,5 @@ "RelatedTo": [ "blockstorage_volume_metadata_sensitive_data" ], - "Notes": "This check uses the detect-secrets library to scan for credentials. May produce false positives on metadata keys containing secret-like keywords. Findings should be reviewed manually. The audit_config allows configuring secrets_ignore_patterns to exclude specific patterns and detect_secrets_plugins to customize detection." + "Notes": "This check uses Kingfisher to scan for credentials. May produce false positives on metadata keys containing secret-like keywords. Findings should be reviewed manually. The audit_config allows configuring secrets_ignore_patterns to exclude specific patterns." } diff --git a/prowler/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data.py b/prowler/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data.py index 95de628871..51e25bc5eb 100644 --- a/prowler/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data.py +++ b/prowler/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data.py @@ -2,7 +2,11 @@ import json from typing import List from prowler.lib.check.models import Check, CheckReportOpenStack -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.openstack.services.blockstorage.blockstorage_client import ( blockstorage_client, ) @@ -16,30 +20,42 @@ class blockstorage_snapshot_metadata_sensitive_data(Check): secrets_ignore_patterns = blockstorage_client.audit_config.get( "secrets_ignore_patterns", [] ) + validate = blockstorage_client.audit_config.get("secrets_validate", False) + snapshots = list(blockstorage_client.snapshots) - for snapshot in blockstorage_client.snapshots: + # Collect one payload per snapshot (its metadata) and scan them all in + # batched Kingfisher invocations instead of one subprocess per snapshot. + def payloads(): + for index, snapshot in enumerate(snapshots): + if snapshot.metadata: + yield index, json.dumps(dict(snapshot.metadata), indent=2) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, snapshot in enumerate(snapshots): report = CheckReportOpenStack(metadata=self.metadata(), resource=snapshot) report.status = "PASS" report.status_extended = f"Snapshot {snapshot.name} ({snapshot.id}) metadata does not contain sensitive data." if snapshot.metadata: - # Build metadata dict and parallel list of keys - dump_metadata = {} - original_metadata_keys = [] - for key, value in snapshot.metadata.items(): - dump_metadata[key] = value - original_metadata_keys.append(key) - - # Convert metadata dict to JSON string for detect-secrets scanning - metadata_json = json.dumps(dump_metadata, indent=2) - detect_secrets_output = detect_secrets_scan( - data=metadata_json, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=blockstorage_client.audit_config.get( - "detect_secrets_plugins" - ), - ) - + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan snapshot {snapshot.name} ({snapshot.id}) " + f"metadata for secrets: {scan_error}; manual review is " + "required." + ) + findings.append(report) + continue + original_metadata_keys = list(snapshot.metadata.keys()) + detect_secrets_output = batch_results.get(index) if detect_secrets_output: # Map line numbers back to metadata keys using the parallel list # Line numbering: line 1 = "{", line 2 = first key-value, etc. @@ -54,6 +70,7 @@ class blockstorage_snapshot_metadata_sensitive_data(Check): ) report.status = "FAIL" report.status_extended = f"Snapshot {snapshot.name} ({snapshot.id}) metadata contains potential secrets -> {secrets_string}." + annotate_verified_secrets(report, detect_secrets_output) else: report.status_extended = f"Snapshot {snapshot.name} ({snapshot.id}) has no metadata (no sensitive data exposure risk)." diff --git a/prowler/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data.metadata.json b/prowler/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data.metadata.json index ec17ee02d1..79874db214 100644 --- a/prowler/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data.metadata.json +++ b/prowler/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data.metadata.json @@ -34,5 +34,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "This check uses the detect-secrets library to scan for credentials. May produce false positives on metadata keys containing secret-like keywords. Findings should be reviewed manually. The audit_config allows configuring secrets_ignore_patterns to exclude specific patterns and detect_secrets_plugins to customize detection." + "Notes": "This check uses Kingfisher to scan for credentials. May produce false positives on metadata keys containing secret-like keywords. Findings should be reviewed manually. The audit_config allows configuring secrets_ignore_patterns to exclude specific patterns." } diff --git a/prowler/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data.py b/prowler/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data.py index 1bfa84c3df..48e064642a 100644 --- a/prowler/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data.py +++ b/prowler/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data.py @@ -2,7 +2,11 @@ import json from typing import List from prowler.lib.check.models import Check, CheckReportOpenStack -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.openstack.services.blockstorage.blockstorage_client import ( blockstorage_client, ) @@ -16,30 +20,41 @@ class blockstorage_volume_metadata_sensitive_data(Check): secrets_ignore_patterns = blockstorage_client.audit_config.get( "secrets_ignore_patterns", [] ) + validate = blockstorage_client.audit_config.get("secrets_validate", False) + volumes = list(blockstorage_client.volumes) - for volume in blockstorage_client.volumes: + # Collect one payload per volume (its metadata) and scan them all in + # batched Kingfisher invocations instead of one subprocess per volume. + def payloads(): + for index, volume in enumerate(volumes): + if volume.metadata: + yield index, json.dumps(dict(volume.metadata), indent=2) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, volume in enumerate(volumes): report = CheckReportOpenStack(metadata=self.metadata(), resource=volume) report.status = "PASS" report.status_extended = f"Volume {volume.name} ({volume.id}) metadata does not contain sensitive data." if volume.metadata: - # Build metadata dict and parallel list of keys - dump_metadata = {} - original_metadata_keys = [] - for key, value in volume.metadata.items(): - dump_metadata[key] = value - original_metadata_keys.append(key) - - # Convert metadata dict to JSON string for detect-secrets scanning - metadata_json = json.dumps(dump_metadata, indent=2) - detect_secrets_output = detect_secrets_scan( - data=metadata_json, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=blockstorage_client.audit_config.get( - "detect_secrets_plugins" - ), - ) - + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan volume {volume.name} ({volume.id}) metadata " + f"for secrets: {scan_error}; manual review is required." + ) + findings.append(report) + continue + original_metadata_keys = list(volume.metadata.keys()) + detect_secrets_output = batch_results.get(index) if detect_secrets_output: # Map line numbers back to metadata keys using the parallel list # Line numbering: line 1 = "{", line 2 = first key-value, etc. @@ -54,6 +69,7 @@ class blockstorage_volume_metadata_sensitive_data(Check): ) report.status = "FAIL" report.status_extended = f"Volume {volume.name} ({volume.id}) metadata contains potential secrets -> {secrets_string}." + annotate_verified_secrets(report, detect_secrets_output) else: report.status_extended = f"Volume {volume.name} ({volume.id}) has no metadata (no sensitive data exposure risk)." diff --git a/prowler/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data.metadata.json b/prowler/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data.metadata.json index 015a00986d..c7f3e41f8e 100644 --- a/prowler/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data.metadata.json +++ b/prowler/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data.metadata.json @@ -34,5 +34,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "This check uses the detect-secrets library to scan for credentials. May produce false positives on metadata keys containing secret-like keywords. Findings should be reviewed manually. The audit_config allows configuring secrets_ignore_patterns to exclude specific patterns and detect_secrets_plugins to customize detection. Metadata is world-readable within instance via 169.254.169.254." + "Notes": "This check uses Kingfisher to scan for credentials. May produce false positives on metadata keys containing secret-like keywords. Findings should be reviewed manually. The audit_config allows configuring secrets_ignore_patterns to exclude specific patterns. Metadata is world-readable within instance via 169.254.169.254." } diff --git a/prowler/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data.py b/prowler/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data.py index 5df151c939..0627862da9 100644 --- a/prowler/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data.py +++ b/prowler/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data.py @@ -2,7 +2,11 @@ import json from typing import List from prowler.lib.check.models import Check, CheckReportOpenStack -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.openstack.services.compute.compute_client import compute_client @@ -14,30 +18,42 @@ class compute_instance_metadata_sensitive_data(Check): secrets_ignore_patterns = compute_client.audit_config.get( "secrets_ignore_patterns", [] ) + validate = compute_client.audit_config.get("secrets_validate", False) + instances = list(compute_client.instances) - for instance in compute_client.instances: + # Collect one payload per instance (its metadata) and scan them all in + # batched Kingfisher invocations instead of one subprocess per instance. + def payloads(): + for index, instance in enumerate(instances): + if instance.metadata: + yield index, json.dumps(dict(instance.metadata), indent=2) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, instance in enumerate(instances): report = CheckReportOpenStack(metadata=self.metadata(), resource=instance) report.status = "PASS" report.status_extended = f"Instance {instance.name} ({instance.id}) metadata does not contain sensitive data." if instance.metadata: - # Build metadata dict and parallel list of keys (similar to AWS ECS pattern) - dump_metadata = {} - original_metadata_keys = [] - for key, value in instance.metadata.items(): - dump_metadata[key] = value - original_metadata_keys.append(key) - - # Convert metadata dict to JSON string for detect-secrets scanning - metadata_json = json.dumps(dump_metadata, indent=2) - detect_secrets_output = detect_secrets_scan( - data=metadata_json, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=compute_client.audit_config.get( - "detect_secrets_plugins" - ), - ) - + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan instance {instance.name} ({instance.id}) " + f"metadata for secrets: {scan_error}; manual review is " + "required." + ) + findings.append(report) + continue + original_metadata_keys = list(instance.metadata.keys()) + detect_secrets_output = batch_results.get(index) if detect_secrets_output: # Map line numbers back to metadata keys using the parallel list # Line numbering: line 1 = "{", line 2 = first key-value, etc. @@ -45,11 +61,14 @@ class compute_instance_metadata_sensitive_data(Check): [ f"{secret['type']} in metadata key '{original_metadata_keys[secret['line_number'] - 2]}'" for secret in detect_secrets_output - if secret["line_number"] - 2 < len(original_metadata_keys) + if 0 + <= secret["line_number"] - 2 + < len(original_metadata_keys) ] ) report.status = "FAIL" report.status_extended = f"Instance {instance.name} ({instance.id}) metadata contains potential secrets -> {secrets_string}." + annotate_verified_secrets(report, detect_secrets_output) else: report.status_extended = f"Instance {instance.name} ({instance.id}) has no metadata (no sensitive data exposure risk)." diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data.metadata.json b/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data.metadata.json index b3a39bd3f8..37e7563c27 100644 --- a/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data.metadata.json +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data.metadata.json @@ -35,5 +35,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "This check uses the detect-secrets library to scan for credentials. May produce false positives on metadata keys containing secret-like keywords. Findings should be reviewed manually. The audit_config allows configuring secrets_ignore_patterns to exclude specific patterns and detect_secrets_plugins to customize detection." + "Notes": "This check uses Kingfisher to scan for credentials. May produce false positives on metadata keys containing secret-like keywords. Findings should be reviewed manually. The audit_config allows configuring secrets_ignore_patterns to exclude specific patterns." } diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data.py b/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data.py index 94d281bf32..549be81046 100644 --- a/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data.py +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data.py @@ -2,7 +2,11 @@ import json from typing import List from prowler.lib.check.models import Check, CheckReportOpenStack -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.openstack.services.objectstorage.objectstorage_client import ( objectstorage_client, ) @@ -16,8 +20,26 @@ class objectstorage_container_metadata_sensitive_data(Check): secrets_ignore_patterns = objectstorage_client.audit_config.get( "secrets_ignore_patterns", [] ) + validate = objectstorage_client.audit_config.get("secrets_validate", False) + containers = list(objectstorage_client.containers) - for container in objectstorage_client.containers: + # Collect one payload per container (its metadata) and scan them all in + # batched Kingfisher invocations instead of one subprocess per container. + def payloads(): + for index, container in enumerate(containers): + if container.metadata: + yield index, json.dumps(dict(container.metadata), indent=2) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, container in enumerate(containers): report = CheckReportOpenStack(metadata=self.metadata(), resource=container) report.status = "PASS" report.status_extended = ( @@ -25,23 +47,16 @@ class objectstorage_container_metadata_sensitive_data(Check): ) if container.metadata: - # Build metadata dict and parallel list of keys - dump_metadata = {} - original_metadata_keys = [] - for key, value in container.metadata.items(): - dump_metadata[key] = value - original_metadata_keys.append(key) - - # Convert metadata dict to JSON string for detect-secrets scanning - metadata_json = json.dumps(dump_metadata, indent=2) - detect_secrets_output = detect_secrets_scan( - data=metadata_json, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=objectstorage_client.audit_config.get( - "detect_secrets_plugins" - ), - ) - + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan container {container.name} metadata for " + f"secrets: {scan_error}; manual review is required." + ) + findings.append(report) + continue + original_metadata_keys = list(container.metadata.keys()) + detect_secrets_output = batch_results.get(index) if detect_secrets_output: # Map line numbers back to metadata keys using the parallel list # Line numbering: line 1 = "{", line 2 = first key-value, etc. @@ -56,6 +71,7 @@ class objectstorage_container_metadata_sensitive_data(Check): ) report.status = "FAIL" report.status_extended = f"Container {container.name} metadata contains potential secrets -> {secrets_string}." + annotate_verified_secrets(report, detect_secrets_output) else: report.status_extended = f"Container {container.name} has no metadata (no sensitive data exposure risk)." diff --git a/prowler/providers/oraclecloud/oraclecloud_provider.py b/prowler/providers/oraclecloud/oraclecloud_provider.py index 5aa959d985..b498bbb502 100644 --- a/prowler/providers/oraclecloud/oraclecloud_provider.py +++ b/prowler/providers/oraclecloud/oraclecloud_provider.py @@ -59,6 +59,7 @@ class OraclecloudProvider(Provider): """ _type: str = "oraclecloud" + sdk_only: bool = False _identity: OCIIdentityInfo _session: OCISession _audit_config: dict diff --git a/prowler/providers/vercel/vercel_provider.py b/prowler/providers/vercel/vercel_provider.py index 54ab4627a9..3de89becbe 100644 --- a/prowler/providers/vercel/vercel_provider.py +++ b/prowler/providers/vercel/vercel_provider.py @@ -33,6 +33,7 @@ class VercelProvider(Provider): """Vercel provider.""" _type: str = "vercel" + sdk_only: bool = False _session: VercelSession _identity: VercelIdentityInfo _audit_config: dict diff --git a/pyproject.toml b/pyproject.toml index 37bb1b003a..9b7452b1f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,11 +72,11 @@ dependencies = [ "dash==3.1.1", "dash-bootstrap-components==2.0.3", "defusedxml==0.7.1", - "detect-secrets==1.5.0", "dulwich==1.2.5", "google-api-python-client==2.163.0", "google-auth-httplib2==0.2.0", "jsonschema==4.23.0", + "kingfisher-bin==1.104.0", "kubernetes==32.0.1", "linode-api4==5.45.0", "markdown==3.10.2", @@ -125,7 +125,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"} name = "prowler" readme = "README.md" requires-python = ">=3.10,<3.14" -version = "5.32.0" +version = "5.33.0" [project.scripts] prowler = "prowler.__main__:prowler" diff --git a/skills/prowler-attack-paths-query/SKILL.md b/skills/prowler-attack-paths-query/SKILL.md index 6dad976983..9fedff4472 100644 --- a/skills/prowler-attack-paths-query/SKILL.md +++ b/skills/prowler-attack-paths-query/SKILL.md @@ -2,13 +2,14 @@ name: prowler-attack-paths-query description: > Creates Prowler Attack Paths openCypher queries using the Cartography schema as the source of truth - for node labels, properties, and relationships. Also covers Prowler-specific additions (Internet node, - ProwlerFinding, internal isolation labels) and $provider_uid scoping for predefined queries. + for node labels, properties, and relationships. Covers Prowler-specific additions (Internet node, + ProwlerFinding, internal isolation labels), $provider_uid scoping, and list-property item nodes + with typed `HAS_*` edges that run efficiently on both Neo4j and Amazon Neptune sinks. Trigger: When creating or updating Attack Paths queries. license: Apache-2.0 metadata: author: prowler-cloud - version: "2.0" + version: "3.0" scope: [root, api] auto_invoke: - "Creating Attack Paths queries" @@ -19,36 +20,30 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, Task ## Overview -Attack Paths queries are openCypher queries that analyze cloud infrastructure graphs (ingested via Cartography) to detect security risks like privilege escalation paths, network exposure, and misconfigurations. - -Queries are written in **openCypher Version 9** for compatibility with both Neo4j and Amazon Neptune. +Attack Paths queries are read-only openCypher queries over a Cartography-ingested cloud graph that detect privilege escalation chains, network exposure, and other graph-shaped security risks. Queries are written in openCypher Version 9 so they run on both Neo4j and Amazon Neptune sinks. --- ## Two query audiences -This skill covers two types of queries with different isolation mechanisms: +| | Predefined queries | Custom queries | +| ------------------ | ----------------------------------------------------------- | --------------------------------------------------------------------- | +| Where they live | `api/src/backend/api/attack_paths/queries/{provider}.py` | User-supplied via the custom query API endpoint | +| Provider isolation | `AWSAccount {id: $provider_uid}` anchor + path connectivity | Automatic `_Provider_{uuid}` label injection by `cypher_sanitizer.py` | +| What to write | Chain every MATCH from the `aws` variable | Plain Cypher, no isolation boilerplate | +| Internal labels | Never use | Never use (system-injected) | -| | Predefined queries | Custom queries | -|---|---|---| -| **Where they live** | `api/src/backend/api/attack_paths/queries/{provider}.py` | User/LLM-supplied via the custom query API endpoint | -| **Provider isolation** | `AWSAccount {id: $provider_uid}` anchor + path connectivity | Automatic `_Provider_{uuid}` label injection via `cypher_sanitizer.py` | -| **What to write** | Chain every MATCH from the `aws` variable | Plain Cypher, no isolation boilerplate needed | -| **Internal labels** | Never use (`_ProviderResource`, `_Tenant_*`, `_Provider_*`) | Never use (injected automatically by the system) | +**Predefined queries**: every node must be reachable from the `AWSAccount` root via graph traversal. That is the isolation boundary. -**For predefined queries**: every node must be reachable from the `AWSAccount` root via graph traversal. This is the isolation boundary. - -**For custom queries**: write natural Cypher without isolation concerns. The query runner injects a `_Provider_{uuid}` label into every node pattern before execution, and a post-query filter catches edge cases. +**Custom queries**: write natural Cypher. The runner injects a `_Provider_{uuid}` label into every node pattern, and a post-query filter handles edge cases. --- -## Input Sources +## Input sources -Queries can be created from: +Two sources for new queries: -1. **pathfinding.cloud ID** (e.g., `ECS-001`, `GLUE-001`) - - Reference: https://github.com/DataDog/pathfinding.cloud - - The aggregated `paths.json` is too large for WebFetch. Use Bash: +1. **pathfinding.cloud ID** (e.g. `ECS-001`, `GLUE-001`), the Datadog research catalogue. The aggregated `paths.json` is too large for WebFetch: ```bash # Fetch a single path by ID @@ -64,28 +59,24 @@ Queries can be created from: | jq -r '.[] | select(.id | startswith("ecs")) | "\(.id): \(.name)"' ``` - If `jq` is not available, use `python3 -c "import json,sys; ..."` as a fallback. + If `jq` is unavailable, use `python3 -c "import json,sys; ..."`. -2. **Natural language description** from the user +2. **Natural language description** from the requester. --- -## Query Structure +## Query structure ### Provider scoping parameter -One parameter is injected automatically by the query runner: +| Parameter | Property | Used on | Purpose | +| --------------- | -------- | ------------ | -------------------------------------- | +| `$provider_uid` | `id` | `AWSAccount` | Scopes the query to a specific account | -| Parameter | Property it matches | Used on | Purpose | -| --------------- | ------------------- | ------------ | -------------------------------- | -| `$provider_uid` | `id` | `AWSAccount` | Scopes to a specific AWS account | - -All other nodes are isolated by path connectivity from the `AWSAccount` anchor. +The runner binds `$provider_uid` automatically. Every other node is isolated by path connectivity from the `AWSAccount` anchor. ### Imports -All query files start with these imports: - ```python from api.attack_paths.queries.types import ( AttackPathsQueryAttribution, @@ -95,29 +86,33 @@ from api.attack_paths.queries.types import ( from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL ``` -The `PROWLER_FINDING_LABEL` constant (value: `"ProwlerFinding"`) is used via f-string interpolation in all queries. Never hardcode the label string. +Always use `PROWLER_FINDING_LABEL` via f-string interpolation, never hardcode `"ProwlerFinding"`. -### Privilege escalation sub-patterns +### Definition fields -There are four distinct privilege escalation patterns. Choose based on the attack type: +- **id**: kebab-case `{provider}-{description}`, e.g. `aws-ec2-privesc-passrole-iam`. +- **name**: short, human-friendly label. Sourced queries append the reference ID: `"EC2 Instance Launch with Privileged Role (EC2-001)"`. +- **short_description**: one sentence, no technical permissions. +- **description**: full technical explanation, plain text. +- **provider**: `aws`, `azure`, `gcp`, `kubernetes`, or `github`. +- **cypher**: f-string Cypher body. Literal `{` / `}` are escaped as `{{` / `}}`. +- **parameters**: `parameters=[]` if none. +- **attribution**: optional `AttackPathsQueryAttribution(text, link)` for sourced queries. `link` uses the lowercase ID. -| Sub-pattern | Target | `path_target` shape | Example | -|---|---|---|---| -| Self-escalation | Principal's own policies | `(aws)--(target_policy:AWSPolicy)--(principal)` | IAM-001 | -| Lateral to user | Other IAM users | `(aws)--(target_user:AWSUser)` | IAM-002 | -| Assume-role lateral | Assumable roles | `(aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal)` | IAM-014 | -| PassRole + service | Service-trusting roles | `(aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(...)` | EC2-001 | +Append the constant to the `{PROVIDER}_QUERIES` list at the bottom of the provider file. -#### Self-escalation (e.g., IAM-001) +--- -The principal modifies resources attached to itself. `path_target` loops back to `principal`: +## Predefined query template + +The canonical shape combines a principal walk, an optional target walk, deduplicated nodes, and a typed finding overlay: ```python AWS_{QUERY_NAME} = AttackPathsQueryDefinition( id="aws-{kebab-case-name}", - name="{Human-friendly label} ({REFERENCE_ID})", - short_description="{Brief explanation, no technical permissions.}", - description="{Detailed description of the attack vector and impact.}", + name="{Label} ({REFERENCE_ID})", + short_description="{One sentence.}", + description="{Full technical explanation.}", attribution=AttackPathsQueryAttribution( text="pathfinding.cloud - {REFERENCE_ID} - {permission}", link="https://pathfinding.cloud/paths/{reference_id_lowercase}", @@ -125,29 +120,27 @@ AWS_{QUERY_NAME} = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with {permission} - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = '{permission_lowercase}' - OR toLower(action) = '{service}:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['{permission_lowercase}', '{service}:*'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal - // Find target resources attached to the same principal + // Target resources attached to the same principal (sub-patterns below) MATCH path_target = (aws)--(target_policy:AWSPolicy)--(principal) WHERE target_policy.arn CONTAINS $provider_uid - AND any(resource IN stmt.resource WHERE - resource = '*' - OR target_policy.arn CONTAINS resource - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR target_policy.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -155,158 +148,145 @@ AWS_{QUERY_NAME} = AttackPathsQueryDefinition( ) ``` -#### Other sub-pattern `path_target` shapes +Key points: -The other 3 sub-patterns share the same `path_principal`, deduplication tail, and RETURN as self-escalation. Only the `path_target` MATCH differs: +- The principal walk types the `POLICY` and `STATEMENT` hops. Both are low-fan-out (each principal has a handful of policies; each policy a handful of statements), so the typed edge lets the planner cost a cheap inline filter. +- The `(aws)--` hub hops stay anonymous. `AWSAccount` is a high-degree node that fans out to every principal, role, policy, and resource in the account; typing those edges forces the planner to enumerate from the hub and collapses performance on multi-tenant Neptune. +- Other relationship types appear only where the file's existing queries already use one (`TRUSTS_AWS_PRINCIPAL`, `STS_ASSUMEROLE_ALLOW`, `MEMBER_AWS_GROUP`, `HAS_EXECUTION_ROLE`). +- The finding probe is typed `:HAS_FINDING` and left undirected. The type lets Neptune apply an inline edge filter; the lack of direction matches the convention of the rest of the file. +- Collapse duplicate rows after each permission gate with `WITH DISTINCT`, carrying only the variables needed by later clauses. +- Each `HAS_*` traversal is its own `MATCH` clause with a `WHERE` on the child item node. `WITH DISTINCT path_principal, path_target` precedes `collect(path...)` to dedupe the row multiplication produced by the joins. +- The `RETURN` shape `paths, dpf, dpfr` is the contract the serializer and visualiser depend on. Do not change it. + +--- + +## Privilege escalation sub-patterns + +Four `path_target` shapes cover the common attack types. Each shares the canonical template's `path_principal`, deduplication tail, and `RETURN`; only the `path_target` MATCH and its resource predicate differ. + +| Sub-pattern | Target | `path_target` shape | Example | +| ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------- | ------- | +| Self-escalation | Principal's own policies | `(aws)--(target_policy:AWSPolicy)--(principal)` | IAM-001 | +| Lateral to user | Other IAM users | `(aws)--(target_user:AWSUser)` | IAM-002 | +| Assume-role lateral | Assumable roles | `(aws)--(target_role:AWSRole)-[:STS_ASSUMEROLE_ALLOW]-(principal)` | IAM-014 | +| PassRole + service | Service-trusting roles | `(aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]-(:AWSPrincipal {arn: '{service}.amazonaws.com'})` | EC2-001 | + +**Multi-permission queries** (e.g. PassRole plus a service-create action) add permission gates before `path_target`. Reuse the per-query counter for new variables (`act2`, `policy2`, `stmt2`) and collapse rows after each gate: ```cypher -// Lateral to user (e.g., IAM-002) - targets other IAM users -MATCH path_target = (aws)--(target_user:AWSUser) -WHERE any(resource IN stmt.resource WHERE resource = '*' OR target_user.arn CONTAINS resource OR resource CONTAINS target_user.name) - -// Assume-role lateral (e.g., IAM-014) - targets roles the principal can assume -MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) -WHERE any(resource IN stmt.resource WHERE resource = '*' OR target_role.arn CONTAINS resource OR resource CONTAINS target_role.name) - -// PassRole + service (e.g., EC2-001) - targets roles trusting a service -MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {arn: '{service}.amazonaws.com'}) -WHERE any(resource IN stmt.resource WHERE resource = '*' OR target_role.arn CONTAINS resource OR resource CONTAINS target_role.name) +MATCH (principal)-[:POLICY]->(policy2:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {effect: 'Allow'}) +MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) +WHERE toLower(act2.value) IN ['service:*', 'service:createsomething'] + OR act2.value = '*' +WITH DISTINCT aws, principal, stmt, stmt2, path_principal ``` -**Multi-permission**: PassRole queries require a second permission. Add `MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)` with its own WHERE before `path_target`, then check BOTH `stmt.resource` AND `stmt2.resource` against the target. See IAM-015 or EC2-001 in `aws.py` for examples. +If a permission is an existence-only gate whose statement resource is not checked later, keep the policy and statement anonymous and carry only the variables still needed: -### Network exposure pattern +```cypher +MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {effect: 'Allow'})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) +WHERE toLower(act3.value) IN ['service:*', 'service:othersomething'] + OR act3.value = '*' +WITH DISTINCT aws, principal, stmt, path_principal +``` -The Internet node is reached via `CAN_ACCESS` through the already-scoped resource, not via a standalone lookup: +When all matching principals can target the same independent resource set, collect principal paths before expanding targets instead of creating one row per principal-target pair: + +```cypher +WITH aws, collect(DISTINCT path_principal) AS principal_paths +MATCH path_target = (aws)--(target) +WITH principal_paths + collect(DISTINCT path_target) AS paths +``` + +Statements that constrain a target are still checked via `HAS_RESOURCE` traversals (`res`, `res2`). See IAM-015 or EC2-001 in `aws.py`. + +--- + +## Network exposure pattern + +The Internet node is reached via `CAN_ACCESS` through an already-scoped resource, never as a standalone lookup: ```python -AWS_{QUERY_NAME} = AttackPathsQueryDefinition( - id="aws-{kebab-case-name}", - name="{Human-friendly label}", - short_description="{Brief explanation.}", - description="{Detailed description.}", - provider="aws", - cypher=f""" - // Match exposed resources (MUST chain from `aws`) - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(resource:EC2Instance) - WHERE resource.exposed_internet = true +cypher=f""" + // Resource scoped through the account anchor + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(resource:EC2Instance) + WHERE resource.exposed_internet = true - // Internet node reached via path connectivity through the resource - OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(resource) + // Internet node reached via path connectivity through the resource + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(resource) - WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access - UNWIND paths AS p - UNWIND nodes(p) AS n + WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access + UNWIND paths AS p + UNWIND nodes(p) AS n - WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes - UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) - RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, - internet, can_access - """, - parameters=[], -) + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, + internet, can_access +""" ``` -### Register in query list - -Add to the `{PROVIDER}_QUERIES` list at the bottom of the file: - -```python -AWS_QUERIES: list[AttackPathsQueryDefinition] = [ - # ... existing queries ... - AWS_{NEW_QUERY_NAME}, # Add here -] -``` +The `CAN_ACCESS` edge stays typed and directed (`-[:CAN_ACCESS]->`); that is its canonical sync-time orientation. --- -## Step-by-step creation process +## List-typed properties as child nodes -### 1. Read the queries module +Some Cartography node properties carry a list of values: `AWSPolicyStatement.action`, `AWSPolicyStatement.resource`, `KMSKey.encryption_algorithms`, `CloudFrontDistribution.aliases`, and many others. The graph models each such property as a set of child item nodes connected to the parent by a typed edge. Queries reach the values by traversing the edge; the parent does not carry the list as a single field. -**FIRST**, read all files in the queries module to understand the structure, type definitions, registration, and existing style: +### Naming convention -```text -api/src/backend/api/attack_paths/queries/ -├── __init__.py # Module exports -├── types.py # AttackPathsQueryDefinition, AttackPathsQueryParameterDefinition -├── registry.py # Query registry logic -└── {provider}.py # Provider-specific queries (e.g., aws.py) +For a list-typed parent property the sink stores: + +- **Child label**: `Item`. Example: `AWSPolicyStatement.resource` → `AWSPolicyStatementResourceItem`. +- **Edge type**: `HAS_`. Example: `resource` → `HAS_RESOURCE`. +- **Child property**: `value` (a single scalar string) for scalar-list properties. For list-of-dict properties (rare; for example `SecretsManagerSecretVersion.tags`) the child carries the dict keys as named fields per the catalog's `field_map`. + +### Variable naming for child-item matches + +`aws.py` uses a per-query counter for each `HAS_*` traversal so chained matches stay unambiguous: + +| Edge | First | Second | Third | +| ----------------- | ------ | ------- | ------- | +| `HAS_ACTION` | `act` | `act2` | `act3` | +| `HAS_RESOURCE` | `res` | `res2` | `res3` | +| `HAS_NOTACTION` | `nact` | `nact2` | `nact3` | +| `HAS_NOTRESOURCE` | `nres` | `nres2` | `nres3` | + +The counter resets at the top of every query. + +### Example - action match + +Find statements that grant `iam:PassRole`, `iam:*`, or `*`. Traverse the `HAS_ACTION` edge in its own `MATCH` clause and apply the predicate in the attached `WHERE`: + +```cypher +MATCH (stmt:AWSPolicyStatement {effect: 'Allow'}) +MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) +WHERE toLower(act.value) IN ['iam:passrole', 'iam:*'] + OR act.value = '*' ``` -**DO NOT** use generic templates. Match the exact style of existing queries in the file. +The literal-action list is case-folded with `toLower(act.value)` because IAM authors mix case (`iam:PassRole`, `iam:passrole`); the `*` wildcard never lower-cases. -### 2. Fetch and consult the Cartography schema +### Example - resource ARN match -**This is the most important step.** Every node label, property, and relationship in the query must exist in the Cartography schema for the pinned version. Do not guess or rely on memory. +Find statements whose resource can target a specific role: -Check `api/pyproject.toml` for the Cartography dependency, then fetch the schema: - -```bash -grep cartography api/pyproject.toml +```cypher +MATCH path_target = (aws)--(target_role:AWSRole) +MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) +WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value ``` -Build the schema URL (ALWAYS use the specific tag, not master/main): +Three predicates cover the cases: full wildcard (`*`), pattern containing the role name (`arn:aws:iam::*:role/admin*`), and pattern that is a prefix or component of the actual ARN. -```text -# Git dependency (prowler-cloud/cartography@0.126.1): -https://raw.githubusercontent.com/prowler-cloud/cartography/refs/tags/0.126.1/docs/root/modules/{provider}/schema.md +### Catalog of list properties -# PyPI dependency (cartography = "^0.126.0"): -https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.126.0/docs/root/modules/{provider}/schema.md -``` - -Read the schema to discover available node labels, properties, and relationships for the target resources. Internal labels (`_ProviderResource`, `_AWSResource`, `_Tenant_*`, `_Provider_*`) exist for isolation but should never appear in queries. - -### 4. Create query definition - -Use the appropriate pattern (privilege escalation or network exposure) with: - -- **id**: `{provider}-{kebab-case-description}` -- **name**: Short, human-friendly label. For sourced queries, append the reference ID: `"EC2 Instance Launch with Privileged Role (EC2-001)"`. -- **short_description**: Brief explanation, no technical permissions. -- **description**: Full technical explanation. Plain text only. -- **provider**: Provider identifier (aws, azure, gcp, kubernetes, github) -- **cypher**: The openCypher query with proper escaping -- **parameters**: Optional list of user-provided parameters (`parameters=[]` if none) -- **attribution**: Optional `AttackPathsQueryAttribution(text, link)` for sourced queries. The `text` includes source, reference ID, and permissions. The `link` uses a lowercase ID. Omit for non-sourced queries. - -### 5. Add query to provider list - -Add the constant to the `{PROVIDER}_QUERIES` list. - ---- - -## Query naming conventions - -### Query ID - -```text -{provider}-{category}-{description} -``` - -Examples: `aws-ec2-privesc-passrole-iam`, `aws-ec2-instances-internet-exposed` - -### Query constant name - -```text -{PROVIDER}_{CATEGORY}_{DESCRIPTION} -``` - -Examples: `AWS_EC2_PRIVESC_PASSROLE_IAM`, `AWS_EC2_INSTANCES_INTERNET_EXPOSED` - ---- - -## Query categories - -| Category | Description | Example | -| -------------------- | ------------------------------ | ------------------------- | -| Basic Resource | List resources with properties | RDS instances, S3 buckets | -| Network Exposure | Internet-exposed resources | EC2 with public IPs | -| Privilege Escalation | IAM privilege escalation paths | PassRole + RunInstances | -| Data Access | Access to sensitive data | EC2 with S3 access | +The provider catalog lives in `api/src/backend/tasks/jobs/attack_paths/provider_config.py` (`AWS_NORMALIZED_LISTS`). Beyond policy statements it includes KMS algorithms, ECS container-definition lists (`entry_point`, `command`, `links`, `dns_servers`, ...), CloudFront aliases, Inspector finding URL and vulnerability lists, RDS event-subscription categories, and others. To query a list property that is not in the catalog, add an entry there first so the sync layer materialises it. --- @@ -315,53 +295,42 @@ Examples: `AWS_EC2_PRIVESC_PASSROLE_IAM`, `AWS_EC2_INSTANCES_INTERNET_EXPOSED` ### Match account and principal ```cypher -MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) +MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {effect: 'Allow'}) ``` -### Check IAM action permissions +The `(aws)--(principal)` hop stays anonymous; the `POLICY` and `STATEMENT` hops are typed. + +### Roles trusting a service ```cypher -WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) +MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]-(:AWSPrincipal {arn: 'ec2.amazonaws.com'}) ``` -### Find roles trusting a service +### Roles a principal can assume ```cypher -MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {arn: 'ec2.amazonaws.com'}) +MATCH path_target = (aws)--(target_role:AWSRole)-[:STS_ASSUMEROLE_ALLOW]-(principal) ``` -### Find roles the principal can assume +### JSON-encoded properties -Note the arrow direction - `STS_ASSUMEROLE_ALLOW` points from the role to the principal: +Object-typed Cartography properties (most notably `condition` on `AWSPolicyStatement` and `S3PolicyStatement`) are stored as JSON-encoded strings, e.g. `'{"StringEquals":{"aws:SourceAccount":"123456789012"}}'`. There is no JSON parser at query time, so use `CONTAINS` for substring checks: ```cypher -MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) +WHERE stmt.condition CONTAINS '"aws:SourceAccount"' ``` -### Check resource scope - -```cypher -WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name -) -``` +For structured inspection, fetch the rows and parse in Python. Cypher cannot navigate JSON object keys. ### Internet node via path connectivity -The Internet node is reached through `CAN_ACCESS` relationships to already-scoped resources. No standalone lookup needed: - ```cypher OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(resource) ``` -### Multi-label OR (match multiple resource types) +`resource` must already be bound by the account-anchored pattern above. + +### Multi-label OR (multiple resource types) ```cypher MATCH path = (aws:AWSAccount {id: $provider_uid})-[r]-(x)-[q]-(y) @@ -373,7 +342,7 @@ WHERE (x:EC2PrivateIp AND x.public_ip = $ip) ### Include Prowler findings -Deduplicate nodes before the ProwlerFinding lookup to avoid redundant OPTIONAL MATCH calls on nodes that appear in multiple paths: +Deduplicate nodes before the typed finding probe to avoid one `OPTIONAL MATCH` per path-occurrence of the same node: ```cypher WITH collect(path_principal) + collect(path_target) AS paths @@ -382,12 +351,12 @@ UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n -OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) +OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr ``` -For network exposure queries, aggregate the internet node and relationship alongside paths: +For network-exposure queries, aggregate the Internet node and its edge alongside paths: ```cypher WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access @@ -396,7 +365,7 @@ UNWIND nodes(p) AS n WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n -OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) +OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access @@ -406,22 +375,22 @@ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, ## Prowler-specific labels and relationships -These are added by the sync task, not part of the Cartography schema. For all other node labels, properties, and relationships, **always consult the Cartography schema** (see step 2 below). +Added by the sync task, not part of the Cartography schema. For everything else, consult the pinned Cartography schema (see "Creation steps"). -| Label/Relationship | Description | -| ---------------------- | -------------------------------------------------- | -| `ProwlerFinding` | Finding node (`status`, `severity`, `check_id`) | -| `Internet` | Internet sentinel node | -| `CAN_ACCESS` | Internet-to-resource exposure (relationship) | -| `HAS_FINDING` | Resource-to-finding link (relationship) | -| `TRUSTS_AWS_PRINCIPAL` | Role trust relationship | -| `STS_ASSUMEROLE_ALLOW` | Can assume role (direction: role -> principal) | +| Label / Relationship | Description | +| ---------------------- | ----------------------------------------------------------- | +| `ProwlerFinding` | Finding node (`status`, `severity`, `check_id`) | +| `Internet` | Internet sentinel node | +| `CAN_ACCESS` | `(Internet)-[:CAN_ACCESS]->(resource)` exposure edge | +| `HAS_FINDING` | `(resource)-[:HAS_FINDING]->(:ProwlerFinding)` finding link | +| `TRUSTS_AWS_PRINCIPAL` | Role trust relationship | +| `STS_ASSUMEROLE_ALLOW` | Can assume role | --- ## Parameters -For queries requiring user input: +For queries that take user input: ```python parameters=[ @@ -438,50 +407,83 @@ parameters=[ --- -## Best practices +## openCypher compatibility -1. **Chain all MATCHes from the root account node**: Every `MATCH` clause must connect to the `aws` variable (or another variable already bound to the account's subgraph). An unanchored `MATCH` would return nodes from all providers. +Queries must run on both Neo4j and Amazon Neptune. Avoid these constructs: - ```cypher - // WRONG: matches ALL AWSRoles across all providers - MATCH (role:AWSRole) WHERE role.name = 'admin' +| Feature | Use instead | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| APOC procedures (`apoc.*`) | Real nodes and relationships in the graph | +| Neptune extensions | Standard openCypher | +| `reduce()` | `UNWIND` + `collect()` | +| `FOREACH` | `WITH` + `UNWIND` + `SET` | +| Regex `=~` | `toLower()` + exact match, or `STARTS WITH` / `CONTAINS` | +| `CALL () { UNION }` | Multi-label `OR` in `WHERE` (see pattern above) | +| `any(x IN list ...)` | `size([x IN list WHERE pred]) > 0` | +| `all(x IN list ...)` | `size([x IN list WHERE pred]) = size(list)` | +| `none(x IN list ...)` | `size([x IN list WHERE pred]) = 0` | +| `EXISTS { MATCH (pattern) WHERE pred }` | Standalone `MATCH (pattern)` + `WHERE pred`; precede the downstream `collect(path...)` with `WITH DISTINCT ` to dedupe the joins | - // CORRECT: scoped to the specific account's subgraph - MATCH (aws)--(role:AWSRole) WHERE role.name = 'admin' - ``` - - **Exception**: A second-permission MATCH like `MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)` is safe because `principal` is already bound to the account's subgraph by the first MATCH. It does not need to chain from `aws` again. - -2. **Include Prowler findings**: Always add `OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})` with `collect(DISTINCT pf)`. - -3. **Comment the query purpose**: Add inline comments explaining each MATCH clause. - -4. **Never use internal labels in queries**: `_ProviderResource`, `_AWSResource`, `_Tenant_*`, `_Provider_*` are for system isolation. They should never appear in predefined or custom query text. - -6. **Internet node uses path connectivity**: Reach it via `OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(resource)` where `resource` is already scoped by the account anchor. No standalone lookup. +For list-typed properties in the catalog (action, resource, and so on), traverse the `HAS_*` edges to the child item nodes via the multi-`MATCH` shape shown in "List-typed properties as child nodes". The parent node does not carry the list as a single field, so `split(...)` and comma-string predicates do not apply. --- -## openCypher compatibility +## Best practices -Queries must be written in **openCypher Version 9** for compatibility with both Neo4j and Amazon Neptune. +1. **Chain every MATCH from the account anchor.** An unanchored `MATCH (role:AWSRole)` returns roles from every provider in the graph; `MATCH (aws)--(role:AWSRole)` is scoped. A second-permission MATCH like `MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)` is safe because `principal` is already bound to the account's subgraph. +2. **Type the finding probe.** Always `OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})`. The type lets Neptune apply an inline edge filter; an untyped probe scans every incident edge of high-degree nodes. +3. **Comment each MATCH.** One inline `// ...` line per clause explaining its role. +4. **Never use internal labels.** `_ProviderResource`, `_AWSResource`, `_Tenant_*`, `_Provider_*` are system isolation labels and must not appear in query text (predefined or custom). +5. **Reach the Internet node through path connectivity** via `(internet:Internet)-[:CAN_ACCESS]->(resource)`, never as a standalone match. +6. **Preserve the `RETURN` contract.** `paths, dpf, dpfr` for the standard shape; add `internet, can_access` for network-exposure queries. The serializer and visualiser depend on these names. -### Avoid these (not in openCypher spec) +--- -| Feature | Use instead | -| -------------------------- | ------------------------------------------------------ | -| APOC procedures (`apoc.*`) | Real nodes and relationships in the graph | -| Neptune extensions | Standard openCypher | -| `reduce()` function | `UNWIND` + `collect()` | -| `FOREACH` clause | `WITH` + `UNWIND` + `SET` | -| Regex operator (`=~`) | `toLower()` + exact match, or `CONTAINS`/`STARTS WITH`. One legacy query uses `=~` - do not add new usages | -| `CALL () { UNION }` | Multi-label OR in WHERE (see patterns section) | +## Naming conventions + +- **ID**: kebab-case `{provider}-{category}-{description}`, e.g. `aws-ec2-privesc-passrole-iam`. +- **Constant**: SHOUTING*SNAKE_CASE `{PROVIDER}*{CATEGORY}\_{DESCRIPTION}`, e.g. `AWS_EC2_PRIVESC_PASSROLE_IAM`. + +--- + +## Creation steps + +1. **Read the queries module first** to match the existing style: + + ```text + api/src/backend/api/attack_paths/queries/ + ├── __init__.py + ├── types.py # dataclass definitions + ├── registry.py + └── {provider}.py + ``` + +2. **Fetch the Cartography schema for the pinned version.** Do not guess labels, properties, or relationships. Read the dependency pin: + + ```bash + grep cartography api/pyproject.toml + ``` + + Then fetch the schema for that exact tag: + + ```text + # Git pin (prowler-cloud/cartography@): + https://raw.githubusercontent.com/prowler-cloud/cartography/refs/tags//docs/root/modules/{provider}/schema.md + + # PyPI pin (cartography==): + https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags//docs/root/modules/{provider}/schema.md + ``` + +3. **Build the query** using the canonical predefined template plus the appropriate sub-pattern (privilege escalation or network exposure). For list-typed properties (action/resource/etc.), traverse the exploded child nodes via `[:HAS_ACTION]->(:AWSPolicyStatementActionItem)` etc. (see "List-typed properties as child nodes" and the `AWS_NORMALIZED_LISTS` catalog). + +4. **Register** the constant in the `{PROVIDER}_QUERIES` list at the bottom of the provider file. --- ## Reference -- **pathfinding.cloud**: https://github.com/DataDog/pathfinding.cloud (use `curl | jq`, not WebFetch) -- **Cartography schema**: `https://raw.githubusercontent.com/{org}/cartography/refs/tags/{version}/docs/root/modules/{provider}/schema.md` -- **Neptune openCypher compliance**: https://docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html -- **openCypher spec**: https://github.com/opencypher/openCypher +- **pathfinding.cloud**: https://github.com/DataDog/pathfinding.cloud (use `curl | jq`; the aggregated `paths.json` is too large for WebFetch). +- **Cartography schema** (per pinned tag): `https://raw.githubusercontent.com/{org}/cartography/refs/tags/{tag}/docs/root/modules/{provider}/schema.md`. +- **Neptune openCypher compliance**: https://docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html. +- **openCypher spec**: https://github.com/opencypher/openCypher. +- **Sync converter** (`tasks/jobs/attack_paths/sync.py`): list-typed node properties listed in `tasks/jobs/attack_paths/provider_config.py::AWS_NORMALIZED_LISTS` are materialised as child item nodes + `HAS_*` edges. Properties that are not in the catalog are serialised to a comma-delimited string and emit a one-time warning. Dict-typed properties become JSON strings. Same shape on both sinks. diff --git a/skills/prowler-ui/SKILL.md b/skills/prowler-ui/SKILL.md index f0f2bae08e..5846dd85d0 100644 --- a/skills/prowler-ui/SKILL.md +++ b/skills/prowler-ui/SKILL.md @@ -10,6 +10,7 @@ metadata: scope: [root, ui] auto_invoke: - "Creating/modifying Prowler UI components" + - "Reviewing Prowler UI components" - "Working on Prowler UI structure (actions/adapters/types/hooks)" allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task --- @@ -39,6 +40,18 @@ HeroUI 2.8.4 (LEGACY - do not add new components) - **ALWAYS**: Use `shadcn/ui` + Tailwind (`components/shadcn/`) - **NEVER**: Add new HeroUI components (`components/ui/` is legacy only) +## Design System Discipline (REQUIRED) + +Applies to ALL UI work. The design system is the single source of truth — reuse it exactly, extend it deliberately. + +- **Reuse first, never reinvent.** Before building anything, search `components/shadcn/` and existing usages in the codebase for an equivalent. Do NOT create a custom component, modal wrapper, or primitive when one already exists. +- **Use exactly the defined variants/styles — no more, no less.** At the call site, drive appearance through the component's `variant`/`size`/`tone` props. Never add ad-hoc visual `className` (color, opacity, hover/focus/disabled, spacing-for-looks) to shared controls (`Button`, `SelectTrigger`, `SelectItem`, `Modal`, badges…), and never skip the correct semantic variant. +- **Modals**: only `@/components/shadcn/modal`. **Selects**: `components/shadcn/select`. +- **Colors**: reuse existing semantic tokens from `ui/styles/globals.css`. No raw Tailwind color utilities (e.g. `bg-blue-950/40`), no hex. If no token fits, STOP and ask the design owner — do not invent or near-duplicate tokens. +- **Need a genuinely new variant/token?** That is a design-system change: add it to the shared component API (with design sign-off), then consume it. It is never a call-site decision. + +When reviewing UI PRs, flag: custom modals/primitives that duplicate shadcn, call-site visual `className` on shared controls, raw color utilities, and new variants/tokens introduced without going through the shared component API. + ## DECISION TREES ### Component Placement diff --git a/tests/config/config_test.py b/tests/config/config_test.py index 2a7aecd330..365efbc0c9 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -488,7 +488,7 @@ class Test_Config: with open(json_path, "w") as f: json.dump({"Framework": "CIS", "Provider": "aws"}, f) - mock_dirs.return_value = {"aws": tmpdir} + mock_dirs.return_value = {"aws": [tmpdir]} frameworks = get_available_compliance_frameworks("aws") @@ -497,6 +497,32 @@ class Test_Config: f"{frameworks.count('cis_2.0_aws')} occurrences in: {frameworks}" ) + @mock.patch("prowler.config.config._get_ep_compliance_dirs") + def test_get_available_compliance_frameworks_merges_multiple_ep_dirs_same_provider( + self, mock_dirs + ): + """Frameworks from every package contributing the same provider must + surface, not just the last directory discovered.""" + import json + import tempfile + + with ( + tempfile.TemporaryDirectory() as pkg_a, + tempfile.TemporaryDirectory() as pkg_b, + ): + with open(os.path.join(pkg_a, "cis_1.0_template.json"), "w") as f: + json.dump({"Framework": "CIS", "Provider": "template"}, f) + with open(os.path.join(pkg_b, "nis2_1.0_template.json"), "w") as f: + json.dump({"Framework": "NIS2", "Provider": "template"}, f) + + # Two packages register `prowler.compliance` with the same name. + mock_dirs.return_value = {"template": [pkg_a, pkg_b]} + + frameworks = get_available_compliance_frameworks("template") + + assert "cis_1.0_template" in frameworks + assert "nis2_1.0_template" 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" diff --git a/tests/config/schema/aws_schema_test.py b/tests/config/schema/aws_schema_test.py index ad08e84e3b..d37a2e0bf6 100644 --- a/tests/config/schema/aws_schema_test.py +++ b/tests/config/schema/aws_schema_test.py @@ -3,6 +3,7 @@ constraint surface (CIDRs, account IDs, port ranges, enums, thresholds).""" import pytest +from prowler.config.scan_config_schema import SCAN_CONFIG_SCHEMA from prowler.config.schema.aws import AWSProviderConfig from prowler.config.schema.validator import validate_provider_config @@ -11,6 +12,52 @@ def _validate(raw): return validate_provider_config("aws", raw, AWSProviderConfig) +RESOURCE_LIMIT_KEYS = [ + "max_scanned_resources_per_service", + "max_ebs_snapshots", + "max_backup_recovery_points", + "max_cloudwatch_log_groups", + "max_lambda_functions", + "max_ecs_task_definitions", + "max_codeartifact_packages", +] + + +class Test_AWS_Resource_Limits: + @pytest.mark.parametrize("key", RESOURCE_LIMIT_KEYS) + def test_positive_values_round_trip(self, key): + assert _validate({key: 100}) == {key: 100} + + @pytest.mark.parametrize("key", RESOURCE_LIMIT_KEYS) + def test_null_values_round_trip(self, key): + assert _validate({key: None}) == {key: None} + + @pytest.mark.parametrize("key", RESOURCE_LIMIT_KEYS) + def test_zero_disable_sentinel_round_trips(self, key): + assert _validate({key: 0}) == {key: 0} + + @pytest.mark.parametrize("key", RESOURCE_LIMIT_KEYS) + def test_numeric_strings_are_coerced_to_int(self, key): + assert _validate({key: "100"}) == {key: 100} + + @pytest.mark.parametrize("key", RESOURCE_LIMIT_KEYS) + def test_disable_sentinel_minus_one_round_trips(self, key): + assert _validate({key: -1}) == {key: -1} + + @pytest.mark.parametrize("key", RESOURCE_LIMIT_KEYS) + @pytest.mark.parametrize("value", [True, False]) + def test_booleans_are_dropped_not_coerced_to_int(self, key, value): + assert _validate({key: value}) == {} + + @pytest.mark.parametrize("key", RESOURCE_LIMIT_KEYS) + def test_invalid_strings_are_dropped(self, key): + assert _validate({key: "not-an-int"}) == {} + + @pytest.mark.parametrize("key", RESOURCE_LIMIT_KEYS) + def test_keys_are_exposed_in_scan_config_schema(self, key): + assert key in SCAN_CONFIG_SCHEMA["properties"]["aws"]["properties"] + + class Test_AWS_Threat_Detection_Thresholds: """All threat detection thresholds are documented as fractions in 0..1. The biggest risk of mistyping them is silently disabling the check.""" @@ -129,44 +176,26 @@ class Test_AWS_Enums: assert _validate({"ecr_repository_vulnerability_minimum_severity": level}) == {} -class Test_AWS_Detect_Secrets_Plugins: - def test_plugin_without_limit(self): - out = _validate({"detect_secrets_plugins": [{"name": "AWSKeyDetector"}]}) - assert out == {"detect_secrets_plugins": [{"name": "AWSKeyDetector"}]} - - def test_plugin_with_limit(self): - out = _validate( - { - "detect_secrets_plugins": [ - {"name": "Base64HighEntropyString", "limit": 6.0} - ] - } - ) - assert out == { - "detect_secrets_plugins": [ - {"name": "Base64HighEntropyString", "limit": 6.0} - ] +class Test_AWS_Secrets_Ignore_Files: + def test_valid_file_patterns_round_trip(self): + files = ["*.deps.json", "vendor/*.js"] + assert _validate({"secrets_ignore_files": files}) == { + "secrets_ignore_files": files } - def test_plugin_missing_name_drops_whole_field(self): - # ``name`` is required by the upstream library. - out = _validate({"detect_secrets_plugins": [{"limit": 6.0}]}) - assert out == {} + def test_empty_list_is_valid(self): + assert _validate({"secrets_ignore_files": []}) == {"secrets_ignore_files": []} - def test_extra_plugin_kwargs_pass_through(self): - # Plugins can have arbitrary extra params (extra="allow" on the - # nested model). They must round-trip. - out = _validate( - { - "detect_secrets_plugins": [ - {"name": "Custom", "my_param": "abc", "other": 42} - ] - } - ) - assert out == { - "detect_secrets_plugins": [ - {"name": "Custom", "my_param": "abc", "other": 42} - ] + def test_exposed_in_scan_config_schema(self): + aws_properties = SCAN_CONFIG_SCHEMA["properties"]["aws"]["properties"] + + assert aws_properties["secrets_ignore_files"] == { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "default": None, + "title": "Secrets Ignore Files", } @@ -214,9 +243,5 @@ class Test_AWS_Full_Default_Config_Round_Trips: "threat_detection_enumeration_threshold": 0.3, "threat_detection_llm_jacking_threshold": 0.4, "ec2_high_risk_ports": [25, 110, 8088], - "detect_secrets_plugins": [ - {"name": "AWSKeyDetector"}, - {"name": "Base64HighEntropyString", "limit": 6.0}, - ], } assert _validate(raw) == raw diff --git a/tests/config/schema/bounds_test.py b/tests/config/schema/bounds_test.py index 0e5cad6056..4d8d49bab2 100644 --- a/tests/config/schema/bounds_test.py +++ b/tests/config/schema/bounds_test.py @@ -70,6 +70,18 @@ INT_BOUND_CASES = [ ("vercel", "stale_invitation_threshold_days", 7, 365), ("vercel", "max_owner_percentage", 1, 50), ("vercel", "max_owners", 1, 1000), + # Okta + ("okta", "okta_max_session_idle_minutes", 1, 1440), + ("okta", "okta_max_session_lifetime_minutes", 1, 43200), + ("okta", "okta_admin_console_idle_timeout_max_minutes", 1, 1440), + ("okta", "okta_user_inactivity_max_days", 1, 3650), + # Alibaba Cloud + ("alibabacloud", "max_cluster_check_days", 1, 365), + ("alibabacloud", "max_console_access_days", 30, 180), + ("alibabacloud", "min_log_retention_days", 1, 3650), + ("alibabacloud", "min_rds_audit_retention_days", 1, 3650), + # OpenStack + ("openstack", "image_sharing_threshold", 1, 1000), ] @@ -330,38 +342,6 @@ class TestTrustedIpsValidator: assert _has_error_for(errors, "aws.trusted_ips") -class TestDetectSecretsEntropyBound: - """`detect_secrets_plugins[].limit` is Shannon entropy: 0..10.""" - - @pytest.mark.parametrize("value", [0.0, 3.5, 4.5, 8.0, 10.0]) - def test_valid(self, value): - assert ( - validate_scan_config( - { - "aws": { - "detect_secrets_plugins": [ - {"name": "Base64HighEntropyString", "limit": value} - ] - } - } - ) - == [] - ) - - @pytest.mark.parametrize("value", [-0.1, 10.01, 50]) - def test_invalid(self, value): - errors = validate_scan_config( - { - "aws": { - "detect_secrets_plugins": [ - {"name": "Base64HighEntropyString", "limit": value} - ] - } - } - ) - assert _has_error_for(errors, "aws.detect_secrets_plugins") - - class TestAdapterRobustness: """Top-level adapter behaviour the Prowler App backend depends on.""" diff --git a/tests/config/schema/other_providers_schema_test.py b/tests/config/schema/other_providers_schema_test.py index f4dc5184ab..5cf13918be 100644 --- a/tests/config/schema/other_providers_schema_test.py +++ b/tests/config/schema/other_providers_schema_test.py @@ -117,6 +117,88 @@ class Test_Cloudflare_Schema: assert _validate("cloudflare", {"max_retries": -1}) == {} +class Test_Okta_Schema: + def test_valid_values_round_trip(self): + raw = { + "okta_max_session_idle_minutes": 15, + "okta_max_session_lifetime_minutes": 18 * 60, + "okta_admin_console_idle_timeout_max_minutes": 15, + "okta_user_inactivity_max_days": 35, + "okta_dod_approved_ca_issuer_patterns": [r"\bOU=DoD\b", r"\bOU=ECA\b"], + } + assert _validate("okta", raw) == raw + + def test_zero_idle_minutes_dropped(self): + assert _validate("okta", {"okta_max_session_idle_minutes": 0}) == {} + + def test_negative_inactivity_days_dropped(self): + assert _validate("okta", {"okta_user_inactivity_max_days": -1}) == {} + + def test_full_rate_limit_config_round_trip(self): + raw = { + "okta_requests_per_second": 4.0, + "okta_max_retries": 5, + "okta_request_timeout": 300, + } + assert _validate("okta", raw) == raw + + def test_requests_per_second_zero_allowed(self): + # 0 is documented as "disable throttling" in config.yaml. + assert _validate("okta", {"okta_requests_per_second": 0}) == { + "okta_requests_per_second": 0 + } + + def test_requests_per_second_sub_one_allowed(self): + assert _validate("okta", {"okta_requests_per_second": 0.5}) == { + "okta_requests_per_second": 0.5 + } + + def test_requests_per_second_floor_allowed(self): + # 0.1 is the lowest non-zero rate accepted. + assert _validate("okta", {"okta_requests_per_second": 0.1}) == { + "okta_requests_per_second": 0.1 + } + + def test_requests_per_second_below_floor_dropped(self): + # A tiny rate (e.g. 0.001 -> ~1000s/request) would make scans take + # days or years; it must be rejected, not silently honoured. + assert _validate("okta", {"okta_requests_per_second": 0.001}) == {} + assert _validate("okta", {"okta_requests_per_second": 0.05}) == {} + + def test_requests_per_second_above_max_dropped(self): + assert _validate("okta", {"okta_requests_per_second": 101}) == {} + + def test_requests_per_second_negative_dropped(self): + assert _validate("okta", {"okta_requests_per_second": -1}) == {} + + def test_max_retries_zero_allowed(self): + assert _validate("okta", {"okta_max_retries": 0}) == {"okta_max_retries": 0} + + def test_max_retries_out_of_range_dropped(self): + assert _validate("okta", {"okta_max_retries": -1}) == {} + assert _validate("okta", {"okta_max_retries": 11}) == {} + + def test_request_timeout_zero_allowed(self): + # 0 is documented as "disable the timeout" in config.yaml. + assert _validate("okta", {"okta_request_timeout": 0}) == { + "okta_request_timeout": 0 + } + + def test_request_timeout_in_range_allowed(self): + assert _validate("okta", {"okta_request_timeout": 300}) == { + "okta_request_timeout": 300 + } + + def test_request_timeout_out_of_range_dropped(self): + assert _validate("okta", {"okta_request_timeout": -1}) == {} + assert _validate("okta", {"okta_request_timeout": 3601}) == {} + + def test_non_numeric_value_dropped(self): + # A typo'd string must not flow through to the limiter (it would crash + # the `> 0` comparison during provider init). + assert _validate("okta", {"okta_requests_per_second": "fast"}) == {} + + class Test_Vercel_Schema: def test_owner_percentage_in_range(self): assert _validate("vercel", {"max_owner_percentage": 20}) == { @@ -150,3 +232,33 @@ class Test_Vercel_Schema: "secret_suffixes": ["_KEY", "_SECRET", "_TOKEN"], } assert _validate("vercel", raw) == raw + + +class Test_AlibabaCloud_Schema: + def test_valid_values_round_trip(self): + raw = { + "max_cluster_check_days": 7, + "max_console_access_days": 90, + "min_log_retention_days": 365, + "min_rds_audit_retention_days": 180, + } + assert _validate("alibabacloud", raw) == raw + + def test_zero_cluster_check_days_dropped(self): + assert _validate("alibabacloud", {"max_cluster_check_days": 0}) == {} + + def test_console_access_below_min_dropped(self): + # 30 is the documented floor; anything below produces false positives. + assert _validate("alibabacloud", {"max_console_access_days": 29}) == {} + + +class Test_OpenStack_Schema: + def test_valid_values_round_trip(self): + raw = { + "image_sharing_threshold": 5, + "secrets_ignore_patterns": ["AKIA[0-9A-Z]{16}"], + } + assert _validate("openstack", raw) == raw + + def test_zero_threshold_dropped(self): + assert _validate("openstack", {"image_sharing_threshold": 0}) == {} diff --git a/tests/lib/check/compliance_config_constraint_model_test.py b/tests/lib/check/compliance_config_constraint_model_test.py new file mode 100644 index 0000000000..48f67fd504 --- /dev/null +++ b/tests/lib/check/compliance_config_constraint_model_test.py @@ -0,0 +1,169 @@ +"""Validation coverage for the ConfigRequirements schema. + +``Compliance_Requirement_ConfigConstraint`` is the model behind every +``ConfigRequirements`` entry in the compliance framework JSONs. These tests pin +the operator vocabulary, the value-typing rules (notably that booleans are not +coerced to integers), and that constraints survive the legacy → universal +adaptation used by the App backend and the OCSF/table outputs. +""" + +import json +import pathlib + +import pytest +from pydantic.v1 import ValidationError + +from prowler.lib.check.compliance_models import ( + Compliance, + Compliance_Requirement_ConfigConstraint, + adapt_legacy_to_universal, +) + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] +_CIS_6_0 = _REPO_ROOT / "prowler" / "compliance" / "aws" / "cis_6.0_aws.json" + + +def _load_cis(): + """Load the CIS 6.0 AWS framework JSON via a context manager.""" + with open(_CIS_6_0, encoding="utf-8") as f: + return json.load(f) + + +class Test_Compliance_Requirement_ConfigConstraint: + @pytest.mark.parametrize( + "operator,value", + [ + ("lte", 45), + ("gte", 365), + ("eq", False), + ("in", [1, 2, 3]), + ("subset", ["1.2", "1.3"]), + ("superset", ["RSA-1024", "P-192"]), + ], + ) + def test_valid_operators(self, operator, value): + c = Compliance_Requirement_ConfigConstraint( + Check="some_check", ConfigKey="some_key", Operator=operator, Value=value + ) + assert c.Operator == operator + assert c.Value == value + + def test_invalid_operator_rejected(self): + with pytest.raises(ValidationError): + Compliance_Requirement_ConfigConstraint( + Check="c", ConfigKey="k", Operator="between", Value=1 + ) + + @pytest.mark.parametrize( + "operator,value", + [ + # numeric operators reject non-numeric / boolean values + ("gte", [1, 2]), + ("lte", ["45"]), + ("gte", True), + # set/list operators reject scalars + ("subset", 5), + ("superset", "x"), + ("in", 1), + # eq rejects lists + ("eq", [1, 2]), + ], + ) + def test_value_type_inconsistent_with_operator_rejected(self, operator, value): + # A mistyped Value would otherwise be silently treated as "not satisfied" + # at runtime, forcing a spurious config-not-valid FAIL. + with pytest.raises(ValidationError): + Compliance_Requirement_ConfigConstraint( + Check="c", ConfigKey="k", Operator=operator, Value=value + ) + + def test_boolean_value_not_coerced_to_int(self): + # ``mute_non_default_regions == false`` must stay a bool, not become 0. + c = Compliance_Requirement_ConfigConstraint( + Check="securityhub_enabled", + ConfigKey="mute_non_default_regions", + Operator="eq", + Value=False, + ) + assert c.Value is False + assert isinstance(c.Value, bool) + + def test_list_value_preserved_for_set_operators(self): + c = Compliance_Requirement_ConfigConstraint( + Check="c", ConfigKey="k", Operator="subset", Value=["1.2", "1.3"] + ) + assert isinstance(c.Value, list) + assert c.Value == ["1.2", "1.3"] + + def test_missing_required_fields_rejected(self): + with pytest.raises(ValidationError): + Compliance_Requirement_ConfigConstraint(Check="c", ConfigKey="k") + + def test_provider_defaults_to_none(self): + # Single-provider frameworks omit Provider; it is optional. + c = Compliance_Requirement_ConfigConstraint( + Check="c", ConfigKey="k", Operator="eq", Value=False + ) + assert c.Provider is None + + def test_provider_scopes_constraint(self): + # Universal frameworks tag each constraint with the provider it applies to. + c = Compliance_Requirement_ConfigConstraint( + Check="securityhub_enabled", + Provider="aws", + ConfigKey="mute_non_default_regions", + Operator="eq", + Value=False, + ) + assert c.Provider == "aws" + + +class Test_ConfigRequirements_On_Compliance: + def test_requirements_without_constraints_default_to_none(self): + compliance = Compliance(**_load_cis()) + # Requirement without configurable checks → ConfigRequirements is None. + no_constraint = [r for r in compliance.Requirements if not r.ConfigRequirements] + assert no_constraint + assert no_constraint[0].ConfigRequirements is None + + def test_requirement_with_constraints_parses(self): + compliance = Compliance(**_load_cis()) + with_constraint = [r for r in compliance.Requirements if r.ConfigRequirements] + assert with_constraint, "cis_6.0_aws should declare ConfigRequirements" + constraint = with_constraint[0].ConfigRequirements[0] + assert isinstance(constraint, Compliance_Requirement_ConfigConstraint) + assert constraint.Check + assert constraint.Operator in {"lte", "gte", "eq", "in", "subset", "superset"} + + +class Test_Adapt_Legacy_To_Universal: + def test_config_requirements_carried_to_universal(self): + legacy = Compliance(**_load_cis()) + universal = adapt_legacy_to_universal(legacy) + + legacy_with = {r.Id for r in legacy.Requirements if r.ConfigRequirements} + universal_with = {r.id for r in universal.requirements if r.config_requirements} + assert legacy_with == universal_with + assert universal_with, "expected at least one requirement with constraints" + + # The constraint payload survives as the typed constraint model with the + # same fields (``Provider`` is carried through too, ``None`` for + # single-provider frameworks like CIS AWS). + sample = next(r for r in universal.requirements if r.config_requirements) + entry = sample.config_requirements[0] + assert isinstance(entry, Compliance_Requirement_ConfigConstraint) + assert set(entry.dict()) == { + "Check", + "Provider", + "ConfigKey", + "Operator", + "Value", + } + assert entry.Provider is None + + def test_requirements_without_constraints_are_none_in_universal(self): + legacy = Compliance(**_load_cis()) + universal = adapt_legacy_to_universal(legacy) + without = [r for r in universal.requirements if not r.config_requirements] + assert without + assert without[0].config_requirements is None diff --git a/tests/lib/check/compliance_config_eval_test.py b/tests/lib/check/compliance_config_eval_test.py new file mode 100644 index 0000000000..4acec9bb4c --- /dev/null +++ b/tests/lib/check/compliance_config_eval_test.py @@ -0,0 +1,408 @@ +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from prowler.lib.check.compliance_config_eval import ( + CONFIG_NOT_VALID_PREFIX, + accumulate_group_status, + accumulate_overview_status, + apply_config_status, + build_requirement_config_status, + evaluate_config_constraints, + get_effective_status, + get_scan_audit_config, + get_scan_provider_type, + resolve_requirement_config_status, +) + +CONSTRAINTS = [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45, + } +] + + +class Test_evaluate_config_constraints: + def test_no_constraints_is_compliant(self): + assert evaluate_config_constraints(None, {}) == (True, "") + assert evaluate_config_constraints([], {"x": 1}) == (True, "") + + def test_config_absent_assumes_default_ok(self): + # Key not explicitly set → default assumed adequate. + is_ok, reason = evaluate_config_constraints(CONSTRAINTS, {}) + assert is_ok is True + assert reason == "" + + def test_none_audit_config_is_compliant(self): + assert evaluate_config_constraints(CONSTRAINTS, None) == (True, "") + + def test_lte_satisfied(self): + assert evaluate_config_constraints( + CONSTRAINTS, {"max_unused_access_keys_days": 45} + ) == (True, "") + + def test_lte_violated(self): + is_ok, reason = evaluate_config_constraints( + CONSTRAINTS, {"max_unused_access_keys_days": 120} + ) + assert is_ok is False + # Product-facing message: names the check, the applied value, what the + # requirement needs and how to fix it, in plain language. + assert reason.startswith(CONFIG_NOT_VALID_PREFIX) + assert "iam_user_accesskey_unused" in reason + assert "max_unused_access_keys_days" in reason + assert "set to 120" in reason + assert "45 or lower" in reason + + def test_gte_operator(self): + c = [{"Check": "c", "ConfigKey": "k", "Operator": "gte", "Value": 10}] + assert evaluate_config_constraints(c, {"k": 10})[0] is True + assert evaluate_config_constraints(c, {"k": 9})[0] is False + + def test_eq_operator(self): + c = [{"Check": "c", "ConfigKey": "k", "Operator": "eq", "Value": "HIGH"}] + assert evaluate_config_constraints(c, {"k": "HIGH"})[0] is True + assert evaluate_config_constraints(c, {"k": "LOW"})[0] is False + + def test_in_operator(self): + c = [{"Check": "c", "ConfigKey": "k", "Operator": "in", "Value": [1, 2, 3]}] + assert evaluate_config_constraints(c, {"k": 2})[0] is True + assert evaluate_config_constraints(c, {"k": 9})[0] is False + + def test_subset_operator_allowlist(self): + # Allowlist config: applied list must stay within the secure baseline. + c = [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": ["1.2", "1.3"], + } + ] + assert ( + evaluate_config_constraints( + c, {"recommended_minimal_tls_versions": ["1.2", "1.3"]} + )[0] + is True + ) + # Stricter (subset) still passes. + assert ( + evaluate_config_constraints( + c, {"recommended_minimal_tls_versions": ["1.3"]} + )[0] + is True + ) + # Widening with a weaker value breaks it. + is_ok, reason = evaluate_config_constraints( + c, {"recommended_minimal_tls_versions": ["1.0", "1.2", "1.3"]} + ) + assert is_ok is False + assert "recommended_minimal_tls_versions" in reason + + def test_superset_operator_denylist(self): + # Denylist config: applied list must keep covering the forbidden baseline. + c = [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": ["RSA-1024", "P-192"], + } + ] + assert ( + evaluate_config_constraints( + c, {"insecure_key_algorithms": ["RSA-1024", "P-192"]} + )[0] + is True + ) + # Extra forbidden values are fine. + assert ( + evaluate_config_constraints( + c, {"insecure_key_algorithms": ["RSA-1024", "P-192", "P-224"]} + )[0] + is True + ) + # Removing a forbidden value breaks it. + assert ( + evaluate_config_constraints(c, {"insecure_key_algorithms": ["P-192"]})[0] + is False + ) + + def test_subset_superset_non_list_not_satisfied(self): + sub = [{"Check": "c", "ConfigKey": "k", "Operator": "subset", "Value": ["a"]}] + sup = [{"Check": "c", "ConfigKey": "k", "Operator": "superset", "Value": ["a"]}] + # A scalar applied value cannot satisfy a set constraint. + assert evaluate_config_constraints(sub, {"k": "a"})[0] is False + assert evaluate_config_constraints(sup, {"k": "a"})[0] is False + + def test_mismatched_types_not_satisfied(self): + assert ( + evaluate_config_constraints( + CONSTRAINTS, {"max_unused_access_keys_days": "x"} + )[0] + is False + ) + + def test_multiple_constraints_first_violation_reported(self): + constraints = [ + {"Check": "a", "ConfigKey": "k1", "Operator": "lte", "Value": 45}, + {"Check": "b", "ConfigKey": "k2", "Operator": "lte", "Value": 45}, + ] + is_ok, reason = evaluate_config_constraints(constraints, {"k1": 45, "k2": 90}) + assert is_ok is False + # The first violation (check "b", key "k2", applied 90) is the one reported. + assert "k2" in reason + assert "set to 90" in reason + + +class Test_provider_scoping: + # An AWS-scoped constraint on a config key whose value is too loose. + AWS_CONSTRAINT = [ + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": False, + } + ] + + def test_applies_when_provider_matches(self): + is_ok, _ = evaluate_config_constraints( + self.AWS_CONSTRAINT, {"mute_non_default_regions": True}, "aws" + ) + assert is_ok is False + + def test_skipped_when_provider_differs(self): + # Same loose value, but scanning GCP → the AWS constraint must not fire. + is_ok, reason = evaluate_config_constraints( + self.AWS_CONSTRAINT, {"mute_non_default_regions": True}, "gcp" + ) + assert is_ok is True + assert reason == "" + + def test_none_provider_type_disables_scoping(self): + # Without a known provider every constraint is evaluated (legacy default). + is_ok, _ = evaluate_config_constraints( + self.AWS_CONSTRAINT, {"mute_non_default_regions": True}, None + ) + assert is_ok is False + + def test_provider_match_is_case_insensitive(self): + # A constraint authored as "AWS" must still scope to the "aws" scan, + # not be silently bypassed by a casing mismatch. + constraint = [ + { + "Check": "securityhub_enabled", + "Provider": "AWS", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": False, + } + ] + is_ok, _ = evaluate_config_constraints( + constraint, {"mute_non_default_regions": True}, "aws" + ) + assert is_ok is False + + def test_untagged_constraint_applies_to_any_provider(self): + # Single-provider frameworks omit Provider → always evaluated. + is_ok, _ = evaluate_config_constraints( + CONSTRAINTS, {"max_unused_access_keys_days": 120}, "aws" + ) + assert is_ok is False + + +# A constraint forcing FAIL when the applied value is too loose. +REGION_CONSTRAINT = [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": False, + } +] + + +def _legacy_req(req_id, constraints=None): + """Fake legacy Compliance_Requirement (``Id`` / ``ConfigRequirements``).""" + return SimpleNamespace(Id=req_id, ConfigRequirements=constraints) + + +def _universal_req(req_id, constraints=None): + """Fake UniversalComplianceRequirement (``id`` / ``config_requirements``).""" + return SimpleNamespace(id=req_id, config_requirements=constraints) + + +class Test_build_requirement_config_status: + def test_only_requirements_with_constraints_included(self): + reqs = [_legacy_req("1", CONSTRAINTS), _legacy_req("2", None)] + status = build_requirement_config_status( + reqs, {"max_unused_access_keys_days": 120} + ) + assert set(status) == {"1"} + assert status["1"][0] is False + + def test_supports_universal_requirements(self): + reqs = [_universal_req("u1", REGION_CONSTRAINT)] + status = build_requirement_config_status( + reqs, {"mute_non_default_regions": True} + ) + assert status["u1"][0] is False + + def test_compliant_when_config_satisfied(self): + reqs = [_legacy_req("1", CONSTRAINTS)] + status = build_requirement_config_status( + reqs, {"max_unused_access_keys_days": 30} + ) + assert status["1"] == (True, "") + + +class Test_resolve_requirement_config_status: + def test_memoises_by_requirement_id(self): + cache = {} + req = _legacy_req("1", CONSTRAINTS) + first = resolve_requirement_config_status( + req, {"max_unused_access_keys_days": 120}, cache + ) + assert cache["1"] is first + assert first[0] is False + # A different audit_config is ignored once cached (intended for one build). + second = resolve_requirement_config_status(req, {}, cache) + assert second is first + + def test_requirement_without_constraints_is_ok(self): + cache = {} + req = _legacy_req("1", None) + assert resolve_requirement_config_status(req, {}, cache) == (True, "") + + +class Test_accumulate_overview_status: + def test_fail_wins_over_earlier_pass(self): + p, f, m = set(), set(), set() + accumulate_overview_status(0, "PASS", p, f, m) + accumulate_overview_status(0, "FAIL", p, f, m) + assert (p, f, m) == (set(), {0}, set()) + + def test_pass_after_fail_does_not_double_count(self): + p, f, m = set(), set(), set() + accumulate_overview_status(0, "FAIL", p, f, m) + accumulate_overview_status(0, "PASS", p, f, m) + assert (p, f, m) == (set(), {0}, set()) + + def test_pass_only(self): + p, f, m = set(), set(), set() + accumulate_overview_status(0, "PASS", p, f, m) + assert (p, f, m) == ({0}, set(), set()) + + def test_muted(self): + p, f, m = set(), set(), set() + accumulate_overview_status(0, "Muted", p, f, m) + assert (p, f, m) == (set(), set(), {0}) + + +class Test_accumulate_group_status: + def test_first_status_counted(self): + counts = {"FAIL": 0, "PASS": 0, "Muted": 0} + seen = {} + accumulate_group_status(0, "PASS", counts, seen) + assert counts == {"FAIL": 0, "PASS": 1, "Muted": 0} + assert seen == {0: "PASS"} + + def test_pass_upgraded_to_fail(self): + counts = {"FAIL": 0, "PASS": 0, "Muted": 0} + seen = {} + accumulate_group_status(0, "PASS", counts, seen) + accumulate_group_status(0, "FAIL", counts, seen) + assert counts == {"FAIL": 1, "PASS": 0, "Muted": 0} + assert seen == {0: "FAIL"} + + def test_fail_not_downgraded_by_later_pass(self): + counts = {"FAIL": 0, "PASS": 0, "Muted": 0} + seen = {} + accumulate_group_status(0, "FAIL", counts, seen) + accumulate_group_status(0, "PASS", counts, seen) + assert counts == {"FAIL": 1, "PASS": 0, "Muted": 0} + + def test_same_index_not_double_counted(self): + counts = {"FAIL": 0, "PASS": 0, "Muted": 0} + seen = {} + accumulate_group_status(0, "PASS", counts, seen) + accumulate_group_status(0, "PASS", counts, seen) + assert counts["PASS"] == 1 + + def test_works_with_fail_pass_only_counts(self): + # Level-style counts (no "Muted" key) used by CIS / split tables. + counts = {"FAIL": 0, "PASS": 0} + seen = {} + accumulate_group_status(0, "PASS", counts, seen) + accumulate_group_status(0, "FAIL", counts, seen) + assert counts == {"FAIL": 1, "PASS": 0} + + def test_muted_on_fail_pass_only_counts_raises(self): + # Level-style callers only ever pass PASS/FAIL (they guard on + # ``not finding.muted``). Passing "Muted" to a Muted-less counts must + # fail loudly rather than silently create a bogus key. + counts = {"FAIL": 0, "PASS": 0} + with pytest.raises(KeyError): + accumulate_group_status(0, "Muted", counts, {}) + + +class Test_apply_config_status: + def test_none_config_status_keeps_finding(self): + assert apply_config_status("PASS", "ext", None) == ("PASS", "ext") + + def test_compliant_keeps_finding(self): + assert apply_config_status("PASS", "ext", (True, "")) == ("PASS", "ext") + + def test_invalid_config_forces_fail_and_prepends_reason(self): + # The reason already carries the full product-facing message; it is + # prepended verbatim to the finding's extended status. + reason = f"{CONFIG_NOT_VALID_PREFIX} bad config" + status, extended = apply_config_status("PASS", "ext", (False, reason)) + assert status == "FAIL" + assert extended.startswith(CONFIG_NOT_VALID_PREFIX) + assert reason in extended + assert "ext" in extended + + +class Test_get_effective_status: + def test_none_and_compliant_keep_status(self): + assert get_effective_status("PASS", None) == "PASS" + assert get_effective_status("PASS", (True, "")) == "PASS" + + def test_invalid_config_forces_fail(self): + assert get_effective_status("PASS", (False, "reason")) == "FAIL" + + +class Test_get_scan_audit_config: + def test_returns_empty_without_global_provider(self): + # No global provider set → get_global_provider() returns None → + # ``None.audit_config`` raises AttributeError → safe empty mapping. + with patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=None, + ): + assert get_scan_audit_config() == {} + + +class Test_get_scan_provider_type: + def test_returns_empty_when_no_global_provider(self): + # No global provider set → get_global_provider() returns None → + # ``None.type`` raises AttributeError → scoping disabled (empty string). + with patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=None, + ): + assert get_scan_provider_type() == "" + + def test_returns_global_provider_type(self): + with patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=SimpleNamespace(type="aws"), + ): + assert get_scan_provider_type() == "aws" diff --git a/tests/lib/check/compliance_config_requirements_data_test.py b/tests/lib/check/compliance_config_requirements_data_test.py new file mode 100644 index 0000000000..3b3ba5757f --- /dev/null +++ b/tests/lib/check/compliance_config_requirements_data_test.py @@ -0,0 +1,191 @@ +"""Data-integrity tests for every ``ConfigRequirements`` declared in the shipped +compliance framework JSONs. + +These guard the ~700 constraints added across the frameworks against drift: +- every constraint is well-formed (valid operator, value typed for its operator), +- every constraint targets a check the requirement actually maps (no orphans), +- the region-mute invariant holds (every requirement mapping a region-scoped + check carries the ``mute_non_default_regions == false`` constraint), +- every framework still parses through its model. +""" + +import glob +import json +import pathlib + +import pytest + +from prowler.lib.check.compliance_models import Compliance, ComplianceFramework + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] +_COMPLIANCE_DIR = _REPO_ROOT / "prowler" / "compliance" + +_VALID_OPERATORS = {"lte", "gte", "eq", "in", "subset", "superset"} +# Checks whose result is untrustworthy when non-default regions are muted. +_REGION_CHECKS = { + "accessanalyzer_enabled", + "config_recorder_all_regions_enabled", + "drs_job_exist", + "guardduty_delegated_admin_enabled_all_regions", + "guardduty_is_enabled", + "securityhub_enabled", +} + +_ALL_FILES = sorted(glob.glob(str(_COMPLIANCE_DIR / "**" / "*.json"), recursive=True)) + + +def _load(path): + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _requirements(data): + return data.get("Requirements") or data.get("requirements") or [] + + +def _req_id(req): + return req.get("Id") or req.get("id") + + +def _req_checks(req): + ch = req.get("Checks", req.get("checks")) + checks = set() + if isinstance(ch, dict): + for v in ch.values(): + checks |= set(v or []) + elif isinstance(ch, list): + checks |= set(ch) + return checks + + +def _req_constraints(req): + return req.get("ConfigRequirements") or req.get("config_requirements") or [] + + +def _iter_constraints(): + """Yield (file, req_id, checks, constraint) for every constraint shipped.""" + for path in _ALL_FILES: + data = _load(path) + for req in _requirements(data): + checks = _req_checks(req) + for c in _req_constraints(req): + yield pathlib.Path(path).name, _req_id(req), checks, c + + +_ALL_CONSTRAINTS = list(_iter_constraints()) + + +def test_there_are_constraints_to_validate(): + # Guards against the iteration silently finding nothing (e.g. path change). + assert len(_ALL_CONSTRAINTS) > 100 + + +@pytest.mark.parametrize( + "fname,req_id,checks,constraint", + _ALL_CONSTRAINTS, + ids=[f"{f}:{r}:{c['Check']}" for f, r, _, c in _ALL_CONSTRAINTS], +) +class Test_Constraint_Wellformed: + def test_has_required_keys(self, fname, req_id, checks, constraint): + required = {"Check", "ConfigKey", "Operator", "Value"} + # ``Provider`` is optional (universal frameworks set it, single-provider + # ones omit it); no other key is allowed. + assert required <= set(constraint) <= required | { + "Provider" + }, f"{fname}:{req_id} malformed constraint {constraint}" + + def test_operator_valid(self, fname, req_id, checks, constraint): + assert constraint["Operator"] in _VALID_OPERATORS + + def test_check_is_mapped_by_requirement(self, fname, req_id, checks, constraint): + # No orphan constraints: the target check must be one the requirement runs. + assert constraint["Check"] in checks, ( + f"{fname}:{req_id} constraint targets {constraint['Check']} " + f"which the requirement does not map" + ) + + def test_value_type_matches_operator(self, fname, req_id, checks, constraint): + op, val = constraint["Operator"], constraint["Value"] + if op in ("subset", "superset", "in"): + assert isinstance(val, list), f"{fname}:{req_id} {op} needs a list value" + elif op in ("lte", "gte"): + # Numeric threshold; bool is not a valid threshold even though it is + # an int subclass. + assert isinstance(val, (int, float)) and not isinstance( + val, bool + ), f"{fname}:{req_id} {op} needs a numeric value, got {val!r}" + elif op == "eq": + assert isinstance( + val, (bool, int, float, str) + ), f"{fname}:{req_id} eq needs a scalar value" + + +class Test_Region_Mute_Invariant: + """Every requirement mapping a region-scoped check must carry the + ``mute_non_default_regions == false`` constraint for it.""" + + def test_region_checks_always_constrained(self): + gaps = [] + for path in _ALL_FILES: + data = _load(path) + for req in _requirements(data): + checks = _req_checks(req) + constrained = { + c["Check"] + for c in _req_constraints(req) + if c["ConfigKey"] == "mute_non_default_regions" + } + for region_check in checks & _REGION_CHECKS: + if region_check not in constrained: + gaps.append( + f"{pathlib.Path(path).name}:{_req_id(req)}:{region_check}" + ) + assert not gaps, f"region-mute constraint missing for: {gaps}" + + def test_region_mute_constraints_use_eq_false(self): + for fname, req_id, _checks, c in _ALL_CONSTRAINTS: + if c["ConfigKey"] == "mute_non_default_regions": + assert ( + c["Operator"] == "eq" and c["Value"] is False + ), f"{fname}:{req_id} region-mute must be eq false" + + +class Test_Universal_Provider_Scoping: + """Universal (multi-provider) frameworks map checks per provider, so every + constraint must declare which provider it scopes to and that provider must + actually map the targeted check. Without this a constraint authored for one + provider's check would wrongly apply to scans of every other provider.""" + + def test_multiprovider_constraints_declare_consistent_provider(self): + gaps = [] + for path in _ALL_FILES: + data = _load(path) + for req in _requirements(data): + ch = req.get("Checks", req.get("checks")) + # Only universal frameworks key their checks by provider. + if not isinstance(ch, dict): + continue + for c in _req_constraints(req): + provider = c.get("Provider") + if not provider: + gaps.append( + f"{pathlib.Path(path).name}:{_req_id(req)}:" + f"{c['Check']} missing Provider" + ) + elif c["Check"] not in set(ch.get(provider, [])): + gaps.append( + f"{pathlib.Path(path).name}:{_req_id(req)}:" + f"{c['Check']} not mapped under provider {provider}" + ) + assert not gaps, f"universal constraints with bad Provider: {gaps}" + + +@pytest.mark.parametrize( + "path", _ALL_FILES, ids=[pathlib.Path(p).name for p in _ALL_FILES] +) +def test_every_framework_parses_with_constraints(path): + data = _load(path) + if "Requirements" in data: + Compliance(**data) + else: + ComplianceFramework.parse_obj(data) diff --git a/tests/lib/check/mitre_config_requirements_test.py b/tests/lib/check/mitre_config_requirements_test.py new file mode 100644 index 0000000000..ef3b33a17f --- /dev/null +++ b/tests/lib/check/mitre_config_requirements_test.py @@ -0,0 +1,148 @@ +"""Regression coverage for ConfigRequirements on MITRE requirements. + +``mitre_attack_aws.json`` declares ``ConfigRequirements`` on its requirements, +but ``Mitre_Requirement`` historically did not define the field, so Pydantic +silently dropped the constraints during MITRE parsing and the config validation +logic never saw them. These tests prove the constraints survive parsing and that +a violated MITRE config requirement forces the compliance result to FAIL through +the universal output path. +""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import patch + +from py_ocsf_models.objects.compliance_status import StatusID as ComplianceStatusID + +from prowler.lib.check.compliance_models import ( + Compliance, + Mitre_Requirement, + adapt_legacy_to_universal, +) +from prowler.lib.outputs.compliance.universal.ocsf_compliance import ( + OCSFComplianceOutput, +) + +_MODULE = "prowler.providers.common.provider.Provider.get_global_provider" + + +def _mitre_compliance(check_id): + """A minimal one-requirement MITRE framework with a config constraint.""" + return Compliance( + Framework="MITRE-ATTACK", + Name="MITRE ATT&CK", + Provider="AWS", + Version="", + Description="Test MITRE framework", + Requirements=[ + { + "Name": "Test Technique", + "Id": "T9999", + "Tactics": ["initial-access"], + "SubTechniques": [], + "Platforms": ["AWS"], + "Description": "Requirement T9999", + "TechniqueURL": "https://attack.mitre.org/techniques/T9999", + "Checks": [check_id], + "ConfigRequirements": [ + { + "Check": check_id, + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": False, + } + ], + "Attributes": [ + { + "AWSService": "service", + "Category": "category", + "Value": "value", + "Comment": "comment", + } + ], + } + ], + ) + + +def _finding(check_id, status="PASS", provider="aws"): + finding = SimpleNamespace() + finding.provider = provider + finding.account_uid = "123456789012" + finding.account_name = "test-account" + finding.account_organization_uid = "org-123" + finding.account_organization_name = "test-org" + finding.region = "us-east-1" + finding.status = status + finding.status_extended = f"{check_id} is {status}" + finding.resource_uid = f"arn:aws:iam::123456789012:{check_id}" + finding.resource_name = check_id + finding.resource_details = "details" + finding.resource_metadata = {} + finding.resource_tags = {"Name": "test"} + finding.partition = "aws" + finding.muted = False + finding.check_id = check_id + finding.uid = "test-finding-uid" + finding.timestamp = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + finding.prowler_version = "5.0.0" + finding.metadata = SimpleNamespace( + CheckID=check_id, + CheckTitle=f"Title for {check_id}", + Description=f"Description for {check_id}", + Severity="medium", + ServiceName="iam", + ResourceType="aws-iam-role", + ) + return finding + + +class Test_Mitre_Config_Requirements: + def test_config_requirements_survive_mitre_parsing(self): + """Real mitre_attack_aws.json constraints must not be dropped on parse.""" + compliance = Compliance.parse_file( + "prowler/compliance/aws/mitre_attack_aws.json" + ) + requirement = next(r for r in compliance.Requirements if r.Id == "T1190") + assert isinstance(requirement, Mitre_Requirement) + assert requirement.ConfigRequirements + # And they propagate through the legacy -> universal adapter unchanged. + universal = adapt_legacy_to_universal(compliance) + universal_requirement = next( + r for r in universal.requirements if r.id == "T1190" + ) + assert universal_requirement.config_requirements + assert len(universal_requirement.config_requirements) == len( + requirement.ConfigRequirements + ) + + def test_violating_mitre_config_forces_fail(self): + """A PASS finding becomes FAIL when the MITRE config constraint is violated.""" + check_id = "drs_job_exist" + framework = adapt_legacy_to_universal(_mitre_compliance(check_id)) + findings = [_finding(check_id, "PASS")] + with patch(_MODULE) as mock_gp: + mock_gp.return_value.audit_config = {"mute_non_default_regions": True} + out = OCSFComplianceOutput( + findings=findings, framework=framework, provider="aws" + ) + event = out.data[0] + assert event.compliance.status_id == ComplianceStatusID.Fail + assert event.status_code == "FAIL" + assert "Configuration not valid" in event.message + # The nested Check object keeps the real (raw) finding status. + assert event.compliance.checks[0].status == "PASS" + + def test_valid_mitre_config_keeps_pass(self): + check_id = "drs_job_exist" + framework = adapt_legacy_to_universal(_mitre_compliance(check_id)) + findings = [_finding(check_id, "PASS")] + with patch(_MODULE) as mock_gp: + mock_gp.return_value.audit_config = {"mute_non_default_regions": False} + out = OCSFComplianceOutput( + findings=findings, framework=framework, provider="aws" + ) + event = out.data[0] + assert event.compliance.status_id == ComplianceStatusID.Pass + assert event.status_code == "PASS" + assert "Configuration not valid" not in event.message diff --git a/tests/lib/outputs/compliance/cis/cis_7_0_m365_test.py b/tests/lib/outputs/compliance/cis/cis_7_0_m365_test.py new file mode 100644 index 0000000000..4fbebb49f6 --- /dev/null +++ b/tests/lib/outputs/compliance/cis/cis_7_0_m365_test.py @@ -0,0 +1,65 @@ +import json +from pathlib import Path + +from prowler.lib.check.compliance_models import ( + CIS_Requirement_Attribute_AssessmentStatus, + CIS_Requirement_Attribute_Profile, + Compliance, +) + +PROWLER_ROOT = Path(__file__).parents[5] / "prowler" +FRAMEWORK_PATH = PROWLER_ROOT / "compliance" / "m365" / "cis_7.0_m365.json" +M365_SERVICES_PATH = PROWLER_ROOT / "providers" / "m365" / "services" + +VALID_PROFILES = {p.value for p in CIS_Requirement_Attribute_Profile} +VALID_STATUSES = {s.value for s in CIS_Requirement_Attribute_AssessmentStatus} + + +def _existing_m365_checks() -> set: + return { + metadata.stem.replace(".metadata", "") + for metadata in M365_SERVICES_PATH.rglob("*.metadata.json") + } + + +class TestCIS7_0_M365: + def test_framework_is_discoverable(self): + frameworks = Compliance.get_bulk("m365") + assert "cis_7.0_m365" in frameworks + + def test_framework_metadata(self): + framework = Compliance.get_bulk("m365")["cis_7.0_m365"] + assert framework.Framework == "CIS" + assert framework.Provider == "M365" + assert framework.Version == "7.0" + assert framework.Name == "CIS Microsoft 365 Foundations Benchmark v7.0.0" + assert len(framework.Requirements) == 160 + + def test_requirement_ids_are_unique(self): + framework = Compliance.get_bulk("m365")["cis_7.0_m365"] + ids = [req.Id for req in framework.Requirements] + assert len(ids) == len(set(ids)) + + def test_each_requirement_has_one_attribute_with_section(self): + framework = Compliance.get_bulk("m365")["cis_7.0_m365"] + for req in framework.Requirements: + assert len(req.Attributes) == 1, f"{req.Id} must have exactly one attribute" + attribute = req.Attributes[0] + assert attribute.Section, f"{req.Id} has an empty Section" + assert attribute.Profile in VALID_PROFILES + assert attribute.AssessmentStatus in VALID_STATUSES + + def test_all_mapped_checks_exist(self): + # Every check referenced by the framework must resolve to a real M365 check, + # otherwise the requirement would never be evaluated. + existing = _existing_m365_checks() + framework = json.loads(FRAMEWORK_PATH.read_text()) + unknown = { + check + for req in framework["Requirements"] + for check in req["Checks"] + if check not in existing + } + assert ( + not unknown + ), f"Framework references unknown M365 checks: {sorted(unknown)}" diff --git a/tests/lib/outputs/compliance/cis/cis_aws_config_requirements_test.py b/tests/lib/outputs/compliance/cis/cis_aws_config_requirements_test.py new file mode 100644 index 0000000000..28cc3d2e1a --- /dev/null +++ b/tests/lib/outputs/compliance/cis/cis_aws_config_requirements_test.py @@ -0,0 +1,89 @@ +"""Integration coverage for requirement-level config validation in the CIS AWS +CSV output. Requirement CIS 6.0 AWS 2.11 maps two configurable checks; when the +scan config is looser than the requirement demands, the requirement row must be +FAIL even if the underlying finding is PASS. The applied config is read from the +active provider's ``audit_config``.""" + +import json +import pathlib +from types import SimpleNamespace +from unittest.mock import patch + +from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.compliance.cis.cis_aws import AWSCIS + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[5] +_CIS_6_0 = _REPO_ROOT / "prowler" / "compliance" / "aws" / "cis_6.0_aws.json" + + +def _load_cis_60() -> Compliance: + return Compliance(**json.load(open(_CIS_6_0))) + + +def _finding(check_id: str, status: str): + return SimpleNamespace( + provider="aws", + account_uid="123456789012", + region="us-east-1", + check_id=check_id, + status=status, + status_extended=f"{check_id} {status}", + resource_uid="arn:aws:iam::123456789012:user/bob", + resource_name="bob", + muted=False, + ) + + +def _rows_for(requirement_id, findings, audit_config): + with patch( + "prowler.providers.common.provider.Provider.get_global_provider" + ) as mock_gp: + mock_gp.return_value.audit_config = audit_config + out = AWSCIS(findings=findings, compliance=_load_cis_60(), file_path=None) + return [r for r in out._data if r.Requirements_Id == requirement_id] + + +class Test_CIS_AWS_Config_Requirements: + def test_loose_config_forces_requirement_fail(self): + findings = [_finding("iam_user_accesskey_unused", "PASS")] + rows = _rows_for("2.11", findings, {"max_unused_access_keys_days": 120}) + assert rows, "expected a row for requirement 2.11" + assert all(r.Status == "FAIL" for r in rows) + assert all("Configuration not valid" in r.StatusExtended for r in rows) + + def test_valid_config_keeps_finding_status(self): + findings = [_finding("iam_user_accesskey_unused", "PASS")] + rows = _rows_for("2.11", findings, {"max_unused_access_keys_days": 45}) + assert rows + assert all(r.Status == "PASS" for r in rows) + assert all("Configuration not valid" not in r.StatusExtended for r in rows) + + def test_absent_config_assumes_default_ok(self): + findings = [_finding("iam_user_accesskey_unused", "PASS")] + rows = _rows_for("2.11", findings, {}) + assert rows + assert all(r.Status == "PASS" for r in rows) + + def test_other_requirements_unaffected(self): + # A finding for a check without ConfigRequirements keeps its status even + # when the config is loose for a different requirement. + findings = [_finding("iam_rotate_access_key_90_days", "PASS")] + rows = _rows_for("2.13", findings, {"max_unused_access_keys_days": 120}) + assert rows + assert all(r.Status == "PASS" for r in rows) + + def test_region_mute_constraint_forces_fail(self): + # Requirement 5.16 maps securityhub_enabled with a + # mute_non_default_regions == false constraint: muting non-default + # regions makes the PASS untrustworthy, so the row must be FAIL. + findings = [_finding("securityhub_enabled", "PASS")] + rows = _rows_for("5.16", findings, {"mute_non_default_regions": True}) + assert rows, "expected a row for requirement 5.16" + assert all(r.Status == "FAIL" for r in rows) + assert all("Configuration not valid" in r.StatusExtended for r in rows) + + def test_region_mute_constraint_default_passes(self): + findings = [_finding("securityhub_enabled", "PASS")] + rows = _rows_for("5.16", findings, {"mute_non_default_regions": False}) + assert rows + assert all(r.Status == "PASS" for r in rows) diff --git a/tests/lib/outputs/compliance/cis/cis_azure_config_requirements_test.py b/tests/lib/outputs/compliance/cis/cis_azure_config_requirements_test.py new file mode 100644 index 0000000000..a34402ec77 --- /dev/null +++ b/tests/lib/outputs/compliance/cis/cis_azure_config_requirements_test.py @@ -0,0 +1,75 @@ +"""Integration coverage for the ``subset`` set-operator in a CSV output. + +CIS Azure 5.0 requirement 9.1.3 maps ``storage_smb_channel_encryption_with_secure_algorithm`` +with a ``recommended_smb_channel_encryption_algorithms subset ["AES-256-GCM"]`` +constraint: widening the allowlist with a weaker algorithm makes the PASS +untrustworthy, so the requirement row must be FAIL. Exercises the shared override +path through a per-provider CSV class (not just OCSF).""" + +import json +import pathlib +from types import SimpleNamespace +from unittest.mock import patch + +from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.compliance.cis.cis_azure import AzureCIS + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[5] +_CIS_5_0_AZURE = _REPO_ROOT / "prowler" / "compliance" / "azure" / "cis_5.0_azure.json" +_REQUIREMENT_ID = "9.1.3" +_CHECK = "storage_smb_channel_encryption_with_secure_algorithm" + + +def _load(): + return Compliance(**json.load(open(_CIS_5_0_AZURE))) + + +def _finding(check_id, status): + return SimpleNamespace( + provider="azure", + account_uid="00000000-0000-0000-0000-000000000000", + region="eastus", + check_id=check_id, + status=status, + status_extended=f"{check_id} {status}", + resource_uid="/subscriptions/x/storageAccounts/sa", + resource_name="sa", + muted=False, + ) + + +def _rows_for(audit_config): + findings = [_finding(_CHECK, "PASS")] + with patch( + "prowler.providers.common.provider.Provider.get_global_provider" + ) as mock_gp: + mock_gp.return_value.audit_config = audit_config + out = AzureCIS(findings=findings, compliance=_load(), file_path=None) + return [r for r in out._data if r.Requirements_Id == _REQUIREMENT_ID] + + +class Test_CIS_Azure_Subset_Constraint: + def test_widened_allowlist_forces_fail(self): + rows = _rows_for( + { + "recommended_smb_channel_encryption_algorithms": [ + "AES-128-CCM", + "AES-256-GCM", + ] + } + ) + assert rows, f"expected a row for requirement {_REQUIREMENT_ID}" + assert all(r.Status == "FAIL" for r in rows) + assert all("Configuration not valid" in r.StatusExtended for r in rows) + + def test_secure_allowlist_keeps_pass(self): + rows = _rows_for( + {"recommended_smb_channel_encryption_algorithms": ["AES-256-GCM"]} + ) + assert rows + assert all(r.Status == "PASS" for r in rows) + + def test_absent_config_keeps_pass(self): + rows = _rows_for({}) + assert rows + assert all(r.Status == "PASS" for r in rows) diff --git a/tests/lib/outputs/compliance/config_status_dispatch_coverage_test.py b/tests/lib/outputs/compliance/config_status_dispatch_coverage_test.py new file mode 100644 index 0000000000..a4f09551a5 --- /dev/null +++ b/tests/lib/outputs/compliance/config_status_dispatch_coverage_test.py @@ -0,0 +1,168 @@ +"""End-to-end coverage: every shipped framework that declares ``ConfigRequirements`` +must apply the config-status override through the *real* table dispatcher. + +The companion ``config_status_renderer_coverage_test`` proves no renderer file +ignores the override. This test closes the other half of the gap that let +``okta_idaas_stig`` ship ConfigRequirements its renderers never applied: it walks +every per-provider compliance JSON that declares constraints, routes a synthetic +PASS finding through ``display_compliance_table`` exactly as a scan would, and +asserts the requirement is forced to FAIL when the scan's config is too loose. + +It runs each framework twice — once with a config that *violates* the first +constraint and once with a config that *satisfies* it — and asserts the violating +run reports strictly more failures. Comparing the two runs is language-neutral +(only the parenthesised counts are read, never the localized PASS/FAIL labels) and +self-checking (a renderer that ignored the override would report equal counts). + +Universal (multi-provider) frameworks render through a different path and are +covered by ``universal/universal_table_config_requirements_test.py``. +""" + +import glob +import io +import json +import pathlib +import re +import tempfile +from contextlib import redirect_stdout +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.compliance.compliance import display_compliance_table + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[4] +_COMPLIANCE_DIR = _REPO_ROOT / "prowler" / "compliance" + +# Per-provider JSONs live in a provider subdir; top-level files are universal. +_PROVIDER_JSONS = sorted(glob.glob(str(_COMPLIANCE_DIR / "*" / "*.json"))) + + +def _first_constraint(data): + """Return ``(check, config_key, operator, value)`` of the first declared + constraint, or ``None`` when the framework declares none.""" + for requirement in data.get("Requirements", []): + constraints = requirement.get("ConfigRequirements") + if constraints: + c = constraints[0] + return c["Check"], c["ConfigKey"], c["Operator"], c["Value"] + return None + + +def _violating_value(operator, value): + """A config value that breaks the constraint (forces the requirement FAIL).""" + if operator == "lte": + return value + 1 + if operator == "gte": + return value - 1 + if operator == "eq": + if isinstance(value, bool): + return not value + if isinstance(value, (int, float)): + return value + 1 + return f"{value}__violates__" + if operator == "in": + return "__not_in_allowed_set__" + if operator == "subset": + return list(value) + ["__extra_not_allowed__"] + if operator == "superset": + return [] + raise AssertionError(f"unhandled operator {operator}") + + +def _satisfying_value(operator, value): + """A config value that satisfies the constraint (requirement keeps its status).""" + if operator in ("lte", "gte", "eq"): + return value + if operator == "in": + return value[0] + if operator == "subset": + return list(value) + if operator == "superset": + return list(value) + raise AssertionError(f"unhandled operator {operator}") + + +def _fail_count(findings, bulk, name, provider, applied_config): + """Render the framework table with ``applied_config`` and return the FAIL + count from the overview, or ``None`` when the table renders nothing.""" + + def _not_implemented(*_a, **_k): + raise NotImplementedError + + fake_provider = SimpleNamespace( + audit_config=applied_config, + type=provider, + display_compliance_table=_not_implemented, + ) + buffer = io.StringIO() + with tempfile.TemporaryDirectory() as tmp: + with patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=fake_provider, + ): + with redirect_stdout(buffer): + display_compliance_table(findings, bulk, name, "output", tmp, False) + plain = re.sub(r"\x1b\[[0-9;]*m", "", buffer.getvalue()) + # The overview's first parenthesised count is always the FAIL tally, in + # every renderer and every locale (only the label is translated). + counts = re.findall(r"\(\s*(\d+)\s*\)", plain) + return int(counts[0]) if counts else None + + +def _frameworks_with_constraints(): + for path in _PROVIDER_JSONS: + with open(path, encoding="utf-8") as f: + data = json.load(f) + if _first_constraint(data): + name = pathlib.Path(path).stem + yield pytest.param(path, data, id=name) + + +@pytest.mark.parametrize("path, data", list(_frameworks_with_constraints())) +def test_framework_constraints_force_fail_through_dispatcher(path, data): + provider = pathlib.Path(path).parent.name + name = pathlib.Path(path).stem + check, config_key, operator, value = _first_constraint(data) + compliance = Compliance(**data) + + def _finding(): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check), + check_id=check, + status="PASS", + muted=False, + ) + + # Two findings: the renderers only print the table when more than one + # finding maps to the framework. + findings = [_finding(), _finding()] + bulk = {check: SimpleNamespace(Compliance=[compliance])} + + strict = _fail_count( + findings, bulk, name, provider, {config_key: _satisfying_value(operator, value)} + ) + loose = _fail_count( + findings, bulk, name, provider, {config_key: _violating_value(operator, value)} + ) + + if strict is None and loose is None: + # The framework's renderer gates rendering on its name/version and does + # not paint a table for this framework id. There is no status to assert + # here; the renderer itself is still proven config-aware by + # config_status_renderer_coverage_test. Surfaced rather than silently + # passed so the skip is visible. + pytest.skip(f"{name}: renderer paints no table for this framework id") + + assert strict == 0, ( + f"{name}: PASS findings reported {strict} failures with a compliant " + "config; the control run should be clean." + ) + assert loose and loose > 0, ( + f"{name}: a PASS finding whose requirement maps {check} ran with " + f"{config_key} too loose for the constraint ({operator} {value}) was NOT " + "forced to FAIL. The framework declares ConfigRequirements its renderer " + "fails to apply — wire it through the config-status helpers." + ) diff --git a/tests/lib/outputs/compliance/config_status_renderer_coverage_test.py b/tests/lib/outputs/compliance/config_status_renderer_coverage_test.py new file mode 100644 index 0000000000..ac0981c941 --- /dev/null +++ b/tests/lib/outputs/compliance/config_status_renderer_coverage_test.py @@ -0,0 +1,71 @@ +"""Guard that every dedicated compliance renderer applies the config-status rule. + +Declaring ``ConfigRequirements`` in a framework JSON is inert unless the renderer +that builds its output actually evaluates them. A requirement whose configurable +checks ran with a config too loose to trust must be forced to FAIL; that override +lives in ``prowler.lib.check.compliance_config_eval`` and every renderer that +emits a finding's status (CSV transform, CLI table, OCSF) must route through it. + +This test statically asserts the invariant: any renderer that reads a finding's +raw ``status`` must also reference one of the config-status helpers. It mirrors +the manual audit that caught ``okta_idaas_stig`` shipping ConfigRequirements that +its CSV and table renderers never applied, so the gap cannot silently reopen. +""" + +import pathlib + +import pytest + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[4] +_RENDERER_DIR = _REPO_ROOT / "prowler" / "lib" / "outputs" / "compliance" + +# Base/dispatch modules that orchestrate renderers but never emit a status row. +_EXCLUDED_BASENAMES = { + "__init__.py", + "models.py", + "compliance.py", + "compliance_check.py", + "compliance_output.py", +} + +# Any one of these, present in the source, means the renderer wires the override. +_CONFIG_STATUS_HELPERS = ( + "apply_config_status", + "build_requirement_config_status", + "resolve_requirement_config_status", + "get_effective_status", +) + +# A renderer builds its output from the finding's raw status via one of these. +_RAW_STATUS_MARKERS = ("finding.status", "finding.status_extended") + + +def _renderer_sources(): + for path in sorted(_RENDERER_DIR.glob("**/*.py")): + if path.name in _EXCLUDED_BASENAMES or "__pycache__" in path.parts: + continue + yield path + + +@pytest.mark.parametrize( + "renderer_path", + [ + pytest.param(p, id=str(p.relative_to(_RENDERER_DIR))) + for p in _renderer_sources() + ], +) +def test_renderer_emitting_status_applies_config_status(renderer_path): + source = renderer_path.read_text(encoding="utf-8") + + uses_raw_status = any(marker in source for marker in _RAW_STATUS_MARKERS) + if not uses_raw_status: + pytest.skip("renderer does not emit a finding's raw status") + + applies_config_status = any(helper in source for helper in _CONFIG_STATUS_HELPERS) + assert applies_config_status, ( + f"{renderer_path.relative_to(_REPO_ROOT)} emits a finding's raw status but " + "never applies the config-status override. Route it through " + "apply_config_status / build_requirement_config_status (CSV/OCSF) or " + "resolve_requirement_config_status / get_effective_status (CLI table), " + "otherwise its ConfigRequirements are silently ignored." + ) diff --git a/tests/lib/outputs/compliance/ens/ens_aws_config_requirements_test.py b/tests/lib/outputs/compliance/ens/ens_aws_config_requirements_test.py new file mode 100644 index 0000000000..b09d9bc0b3 --- /dev/null +++ b/tests/lib/outputs/compliance/ens/ens_aws_config_requirements_test.py @@ -0,0 +1,61 @@ +"""Integration coverage proving the shared requirement-level config validation +is applied beyond CIS. ENS RD2022 AWS requirement ``op.exp.1.aws.cfg.1`` maps +``config_recorder_all_regions_enabled`` with a ``mute_non_default_regions == +false`` constraint; muting non-default regions makes a PASS untrustworthy, so +the requirement row must be FAIL even when the finding PASSes. The applied +config is read from the active provider's ``audit_config``.""" + +import json +import pathlib +from types import SimpleNamespace +from unittest.mock import patch + +from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.compliance.ens.ens_aws import AWSENS + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[5] +_ENS = _REPO_ROOT / "prowler" / "compliance" / "aws" / "ens_rd2022_aws.json" +_REQUIREMENT_ID = "op.exp.1.aws.cfg.1" + + +def _load_ens() -> Compliance: + return Compliance(**json.load(open(_ENS))) + + +def _finding(check_id: str, status: str): + return SimpleNamespace( + provider="aws", + account_uid="123456789012", + region="us-east-1", + check_id=check_id, + status=status, + status_extended=f"{check_id} {status}", + resource_uid="arn:aws:config:us-east-1:123456789012:recorder/default", + resource_name="default", + muted=False, + ) + + +def _rows_for(requirement_id, findings, audit_config): + with patch( + "prowler.providers.common.provider.Provider.get_global_provider" + ) as mock_gp: + mock_gp.return_value.audit_config = audit_config + out = AWSENS(findings=findings, compliance=_load_ens(), file_path=None) + return [r for r in out._data if r.Requirements_Id == requirement_id] + + +class Test_ENS_AWS_Config_Requirements: + def test_region_mute_constraint_forces_fail(self): + findings = [_finding("config_recorder_all_regions_enabled", "PASS")] + rows = _rows_for(_REQUIREMENT_ID, findings, {"mute_non_default_regions": True}) + assert rows, f"expected a row for requirement {_REQUIREMENT_ID}" + assert all(r.Status == "FAIL" for r in rows) + assert all("Configuration not valid" in r.StatusExtended for r in rows) + + def test_default_config_keeps_finding_status(self): + findings = [_finding("config_recorder_all_regions_enabled", "PASS")] + rows = _rows_for(_REQUIREMENT_ID, findings, {}) + assert rows + assert all(r.Status == "PASS" for r in rows) + assert all("Configuration not valid" not in r.StatusExtended for r in rows) 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 index fa616c906e..a6a0267851 100644 --- 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 @@ -5,6 +5,10 @@ from unittest import mock from freezegun import freeze_time from mock import patch +from prowler.lib.check.compliance_config_eval import CONFIG_NOT_VALID_PREFIX +from prowler.lib.check.compliance_models import ( + Compliance_Requirement_ConfigConstraint, +) 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, @@ -137,3 +141,52 @@ class TestOktaIDaaSSTIG: 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 + + def test_config_status_override_forces_fail(self): + """A PASS finding whose requirement declares a ConfigRequirements + constraint the scan's config violates must be reported as FAIL in the + CSV, with the config-not-valid reason prepended to StatusExtended.""" + # Inject a config constraint on the first requirement (idle timeout must + # be <= 15 minutes) without mutating the shared fixture. + compliance = OKTA_IDAAS_STIG_OKTA.copy(deep=True) + compliance.Requirements[0].ConfigRequirements = [ + Compliance_Requirement_ConfigConstraint( + Check="signon_global_session_idle_timeout_15min", + ConfigKey="okta_max_session_idle_minutes", + Operator="lte", + Value=15, + ) + ] + findings = [ + generate_finding_output( + provider="okta", + account_uid=OKTA_ORG_DOMAIN, + account_name=OKTA_ORG_DOMAIN, + region="global", + service_name="signon", + status="PASS", + status_extended="Idle timeout is configured.", + 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"]}, + ) + ] + + # The scan applied a 30-minute idle timeout, too loose for the 15-minute + # requirement, so the PASS must be overridden to FAIL. + with ( + patch( + "prowler.lib.check.compliance_config_eval.get_scan_audit_config", + return_value={"okta_max_session_idle_minutes": 30}, + ), + patch( + "prowler.lib.check.compliance_config_eval.get_scan_provider_type", + return_value="okta", + ), + ): + output = OktaIDaaSSTIG(findings, compliance) + + output_data = output.data[0] + assert output_data.Status == "FAIL" + assert output_data.StatusExtended.startswith(CONFIG_NOT_VALID_PREFIX) 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 index 3017ea4f92..0dd353760b 100644 --- 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 @@ -1,4 +1,5 @@ from types import SimpleNamespace +from unittest.mock import patch from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig import ( get_okta_idaas_stig_table, @@ -8,18 +9,35 @@ from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig import ( def _make_finding(check_id, status="PASS", muted=False): return SimpleNamespace( check_metadata=SimpleNamespace(CheckID=check_id), + check_id=check_id, status=status, muted=muted, ) -def _make_compliance(provider, sections, framework="Okta-IDaaS-STIG"): - """Build a per-check compliance covering the given sections.""" +def _make_compliance( + provider, + sections, + framework="Okta-IDaaS-STIG", + checks=None, + config_requirements=None, +): + """Build a per-check compliance covering the given sections. + + ``checks`` and ``config_requirements`` let a section's requirement declare + the checks it owns and the config constraints that gate it, so the table's + config-status override can be exercised. + """ return SimpleNamespace( Framework=framework, Provider=provider, Requirements=[ - SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + SimpleNamespace( + Id=f"REQ-{section}", + Checks=list(checks or []), + ConfigRequirements=list(config_requirements or []), + Attributes=[SimpleNamespace(Section=section)], + ) for section in sections ], ) @@ -134,3 +152,60 @@ class TestOktaIDaaSSTIGTable: # The provider of the unrelated trailing framework must NOT leak into # the rendered table. assert "aws" not in captured.out + + def test_config_status_override_forces_fail(self, capsys, tmp_path): + """A configurable check that PASSes but ran with a config too loose for + its requirement must be forced to FAIL in the table, honouring the + requirement's ConfigRequirements. Without the override check_a would be + PASS and no results table would render at all.""" + constraint = { + "Check": "check_a", + "ConfigKey": "okta_max_session_idle_minutes", + "Operator": "lte", + "Value": 15, + } + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "okta", + ["IAM"], + checks=["check_a"], + config_requirements=[constraint], + ) + ] + ), + "check_b": SimpleNamespace( + Compliance=[_make_compliance("okta", ["Logging"])] + ), + } + # Both checks PASS on their own; the scan applied a 30-minute idle + # timeout, which is too loose for the 15-minute requirement. + findings = [ + _make_finding("check_a", "PASS"), + _make_finding("check_b", "PASS"), + ] + + with ( + patch( + "prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig.get_scan_audit_config", + return_value={"okta_max_session_idle_minutes": 30}, + ), + patch( + "prowler.lib.check.compliance_config_eval.get_scan_provider_type", + return_value="okta", + ), + ): + get_okta_idaas_stig_table( + findings, + bulk_metadata, + "okta_idaas_stig_1r2", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # check_a was forced to FAIL by the config override, so its section + # (IAM) must report FAIL(1). + assert "FAIL(1)" in captured.out diff --git a/tests/lib/outputs/compliance/universal/ocsf_compliance_config_requirements_test.py b/tests/lib/outputs/compliance/universal/ocsf_compliance_config_requirements_test.py new file mode 100644 index 0000000000..c846385d88 --- /dev/null +++ b/tests/lib/outputs/compliance/universal/ocsf_compliance_config_requirements_test.py @@ -0,0 +1,191 @@ +"""Integration coverage for ConfigRequirements in the OCSF compliance output. + +OCSF is the universal output path every framework renders through, so it is the +natural place to exercise the requirement-level config override end to end across +all operators. When a requirement's configurable check ran with a config too +loose to trust, the Compliance status must be FAIL (even on a PASS finding) and +the message must carry the ``Configuration not valid`` marker. The Check status keeps +the real finding status. +""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from py_ocsf_models.objects.compliance_status import StatusID as ComplianceStatusID + +from prowler.lib.check.compliance_models import ( + ComplianceFramework, + OutputsConfig, + TableConfig, + UniversalComplianceRequirement, +) +from prowler.lib.outputs.compliance.universal.ocsf_compliance import ( + OCSFComplianceOutput, +) + +_MODULE = "prowler.providers.common.provider.Provider.get_global_provider" + + +def _finding(check_id, status="PASS", provider="aws"): + finding = SimpleNamespace() + finding.provider = provider + finding.account_uid = "123456789012" + finding.account_name = "test-account" + finding.account_email = "" + finding.account_organization_uid = "org-123" + finding.account_organization_name = "test-org" + finding.account_tags = {"env": "test"} + finding.region = "us-east-1" + finding.status = status + finding.status_extended = f"{check_id} is {status}" + finding.resource_uid = f"arn:aws:iam::123456789012:{check_id}" + finding.resource_name = check_id + finding.resource_details = "details" + finding.resource_metadata = {} + finding.resource_tags = {"Name": "test"} + finding.partition = "aws" + finding.muted = False + finding.check_id = check_id + finding.uid = "test-finding-uid" + finding.timestamp = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + finding.prowler_version = "5.0.0" + finding.compliance = {} + finding.metadata = SimpleNamespace( + Provider=provider, + CheckID=check_id, + CheckTitle=f"Title for {check_id}", + CheckType=["test-type"], + Description=f"Description for {check_id}", + Severity="medium", + ServiceName="iam", + ResourceType="aws-iam-role", + Risk="risk", + RelatedUrl="https://example.com", + Remediation=SimpleNamespace( + Recommendation=SimpleNamespace(Text="Fix", Url="https://fix.com"), + ), + DependsOn=[], + RelatedTo=[], + Categories=["test"], + Notes="", + AdditionalURLs=[], + ) + return finding + + +def _framework(check_id, constraint): + req = UniversalComplianceRequirement( + id="REQ-1", + description="Requirement REQ-1", + attributes={}, + checks={"aws": [check_id]}, + config_requirements=[constraint], + ) + return ComplianceFramework( + framework="TestFW", + name="Test Framework", + provider="AWS", + version="1.0", + description="Test framework", + requirements=[req], + attributes_metadata=None, + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + + +def _run(check_id, constraint, audit_config): + fw = _framework(check_id, constraint) + findings = [_finding(check_id, "PASS")] + with patch(_MODULE) as mock_gp: + mock_gp.return_value.audit_config = audit_config + out = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + return out.data[0] + + +# (check, constraint, violating_config, valid_config) +_CASES = [ + ( + "securityhub_enabled", + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": False, + }, + {"mute_non_default_regions": True}, + {"mute_non_default_regions": False}, + ), + ( + "iam_user_accesskey_unused", + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45, + }, + {"max_unused_access_keys_days": 120}, + {"max_unused_access_keys_days": 30}, + ), + ( + "cloudwatch_log_group_retention_policy_specific_days_enabled", + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365, + }, + {"log_group_retention_days": 90}, + {"log_group_retention_days": 365}, + ), + ( + "sqlserver_recommended_minimal_tls_version", + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": ["1.2", "1.3"], + }, + {"recommended_minimal_tls_versions": ["1.0", "1.2", "1.3"]}, + {"recommended_minimal_tls_versions": ["1.3"]}, + ), + ( + "acm_certificates_with_secure_key_algorithms", + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": ["RSA-1024", "P-192"], + }, + {"insecure_key_algorithms": ["P-192"]}, + {"insecure_key_algorithms": ["RSA-1024", "P-192"]}, + ), +] + + +class Test_OCSF_Config_Requirements: + @pytest.mark.parametrize( + "check,constraint,bad,ok", + _CASES, + ids=[c[1]["Operator"] for c in _CASES], + ) + def test_violating_config_fails_requirement(self, check, constraint, bad, ok): + cf = _run(check, constraint, bad) + assert cf.compliance.status_id == ComplianceStatusID.Fail + assert "Configuration not valid" in cf.message + + @pytest.mark.parametrize( + "check,constraint,bad,ok", + _CASES, + ids=[c[1]["Operator"] for c in _CASES], + ) + def test_valid_config_keeps_pass(self, check, constraint, bad, ok): + cf = _run(check, constraint, ok) + assert cf.compliance.status_id == ComplianceStatusID.Pass + assert "Configuration not valid" not in cf.message + + def test_absent_config_assumes_default_ok(self): + check, constraint, _bad, _ok = _CASES[0] + cf = _run(check, constraint, {}) + assert cf.compliance.status_id == ComplianceStatusID.Pass diff --git a/tests/lib/outputs/compliance/universal/ocsf_compliance_status_scoping_test.py b/tests/lib/outputs/compliance/universal/ocsf_compliance_status_scoping_test.py new file mode 100644 index 0000000000..8408868296 --- /dev/null +++ b/tests/lib/outputs/compliance/universal/ocsf_compliance_status_scoping_test.py @@ -0,0 +1,129 @@ +"""Top-level status consistency and provider scoping for the OCSF output. + +Two regressions are covered here: + +1. The event's top-level ``status_code``/``status_detail`` must reflect the + effective (config-aware) status, so a config-invalid PASS cannot produce an + event where ``compliance.status_id`` says FAIL while ``status_code`` still + says PASS. The nested Check object keeps the raw finding status. +2. Provider scoping: an Azure-scoped constraint must never affect an AWS output + even when the global provider would otherwise be relied upon. +""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import patch + +from py_ocsf_models.objects.compliance_status import StatusID as ComplianceStatusID + +from prowler.lib.check.compliance_models import ( + ComplianceFramework, + OutputsConfig, + TableConfig, + UniversalComplianceRequirement, +) +from prowler.lib.outputs.compliance.universal.ocsf_compliance import ( + OCSFComplianceOutput, +) + +_MODULE = "prowler.providers.common.provider.Provider.get_global_provider" + + +def _finding(check_id, status="PASS", provider="aws"): + finding = SimpleNamespace() + finding.provider = provider + finding.account_uid = "123456789012" + finding.account_name = "test-account" + finding.account_organization_uid = "org-123" + finding.account_organization_name = "test-org" + finding.region = "us-east-1" + finding.status = status + finding.status_extended = f"{check_id} is {status}" + finding.resource_uid = f"arn:aws:iam::123456789012:{check_id}" + finding.resource_name = check_id + finding.resource_details = "details" + finding.resource_metadata = {} + finding.resource_tags = {"Name": "test"} + finding.partition = "aws" + finding.muted = False + finding.check_id = check_id + finding.uid = "test-finding-uid" + finding.timestamp = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + finding.prowler_version = "5.0.0" + finding.metadata = SimpleNamespace( + CheckID=check_id, + CheckTitle=f"Title for {check_id}", + Description=f"Description for {check_id}", + Severity="medium", + ServiceName="iam", + ResourceType="aws-iam-role", + ) + return finding + + +def _framework(constraint, provider="AWS", check_provider="aws"): + req = UniversalComplianceRequirement( + id="REQ-1", + description="Requirement REQ-1", + attributes={}, + checks={check_provider: ["check_a"]}, + config_requirements=[constraint], + ) + return ComplianceFramework( + framework="TestFW", + name="Test Framework", + provider=provider, + version="1.0", + description="Test framework", + requirements=[req], + attributes_metadata=None, + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + + +def _run(framework, audit_config, provider="aws", status="PASS"): + findings = [_finding("check_a", status, provider)] + with patch(_MODULE) as mock_gp: + mock_gp.return_value.audit_config = audit_config + mock_gp.return_value.type = provider + out = OCSFComplianceOutput( + findings=findings, framework=framework, provider=provider + ) + return out.data[0] + + +_CONSTRAINT = { + "Check": "check_a", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45, +} + + +class Test_OCSF_TopLevel_Status: + def test_config_invalid_pass_forces_toplevel_fail(self): + event = _run(_framework(_CONSTRAINT), {"max_unused_access_keys_days": 120}) + # Top-level and nested compliance status agree: both FAIL. + assert event.compliance.status_id == ComplianceStatusID.Fail + assert event.status_code == "FAIL" + assert "Configuration not valid" in event.status_detail + assert event.status_detail == event.message + # The nested Check preserves the raw finding result. + assert event.compliance.checks[0].status == "PASS" + + def test_valid_config_keeps_toplevel_pass(self): + event = _run(_framework(_CONSTRAINT), {"max_unused_access_keys_days": 30}) + assert event.compliance.status_id == ComplianceStatusID.Pass + assert event.status_code == "PASS" + assert "Configuration not valid" not in event.status_detail + + +class Test_OCSF_Provider_Scoping: + def test_azure_constraint_does_not_affect_aws_output(self): + constraint = {**_CONSTRAINT, "Provider": "azure"} + event = _run( + _framework(constraint), {"max_unused_access_keys_days": 120}, provider="aws" + ) + assert event.compliance.status_id == ComplianceStatusID.Pass + assert event.status_code == "PASS" + assert "Configuration not valid" not in event.status_detail diff --git a/tests/lib/outputs/compliance/universal/universal_output_config_requirements_test.py b/tests/lib/outputs/compliance/universal/universal_output_config_requirements_test.py new file mode 100644 index 0000000000..dd83dad575 --- /dev/null +++ b/tests/lib/outputs/compliance/universal/universal_output_config_requirements_test.py @@ -0,0 +1,115 @@ +"""Coverage for ConfigRequirements + provider scoping in the universal CSV. + +The universal CSV must apply the same effective-status logic as the OCSF/table +outputs: a config-invalid PASS is reported as FAIL instead of leaking the raw +finding status. Provider scoping must also hold, so a constraint scoped to +another provider (e.g. Azure) never affects this provider's output (e.g. AWS). +""" + +from types import SimpleNamespace +from unittest.mock import patch + +from prowler.lib.check.compliance_models import ( + AttributeMetadata, + ComplianceFramework, + OutputsConfig, + TableConfig, + UniversalComplianceRequirement, +) +from prowler.lib.outputs.compliance.universal.universal_output import ( + UniversalComplianceOutput, +) + +_MODULE = "prowler.providers.common.provider.Provider.get_global_provider" + + +def _make_finding(check_id, status="PASS", provider="aws"): + finding = SimpleNamespace() + finding.provider = provider + finding.account_uid = "123456789012" + finding.account_name = "test-account" + finding.region = "us-east-1" + finding.status = status + finding.status_extended = f"{check_id} is {status}" + finding.resource_uid = f"arn:aws:iam::123456789012:{check_id}" + finding.resource_name = check_id + finding.muted = False + finding.check_id = check_id + finding.metadata = SimpleNamespace(Provider=provider, CheckID=check_id) + finding.compliance = {} + return finding + + +def _make_framework(constraint, provider="AWS", check_provider="aws"): + req = UniversalComplianceRequirement( + id="1.1", + description="test requirement", + attributes={"Section": "IAM"}, + checks={check_provider: ["check_a"]}, + config_requirements=[constraint], + ) + return ComplianceFramework( + framework="TestFW", + name="Test Framework", + provider=provider, + version="1.0", + description="Test framework", + requirements=[req], + attributes_metadata=[AttributeMetadata(key="Section", type="str")], + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + + +def _run(framework, audit_config, provider="aws", status="PASS"): + findings = [_make_finding("check_a", status, provider)] + with patch(_MODULE) as mock_gp: + mock_gp.return_value.audit_config = audit_config + mock_gp.return_value.type = provider + out = UniversalComplianceOutput( + findings=findings, framework=framework, provider=provider + ) + return out.data[0].dict() + + +class Test_Universal_CSV_Config_Requirements: + _CONSTRAINT = { + "Check": "check_a", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45, + } + + def test_violating_config_forces_fail(self): + fw = _make_framework(self._CONSTRAINT) + row = _run(fw, {"max_unused_access_keys_days": 120}) + assert row["Status"] == "FAIL" + assert "Configuration not valid" in row["StatusExtended"] + + def test_valid_config_keeps_pass(self): + fw = _make_framework(self._CONSTRAINT) + row = _run(fw, {"max_unused_access_keys_days": 30}) + assert row["Status"] == "PASS" + assert "Configuration not valid" not in row["StatusExtended"] + + def test_absent_config_assumes_default_ok(self): + fw = _make_framework(self._CONSTRAINT) + row = _run(fw, {}) + assert row["Status"] == "PASS" + + +class Test_Universal_CSV_Provider_Scoping: + def test_azure_constraint_does_not_affect_aws_output(self): + """An Azure-scoped constraint must not force an AWS output to FAIL.""" + constraint = { + "Check": "check_a", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45, + "Provider": "azure", + } + fw = _make_framework(constraint) + # Even with a config that *would* violate the constraint, the AWS output + # must keep PASS because the constraint is scoped to Azure. + row = _run(fw, {"max_unused_access_keys_days": 120}, provider="aws") + assert row["Status"] == "PASS" + assert "Configuration not valid" not in row["StatusExtended"] diff --git a/tests/lib/outputs/compliance/universal/universal_table_config_requirements_test.py b/tests/lib/outputs/compliance/universal/universal_table_config_requirements_test.py new file mode 100644 index 0000000000..3ac8dc6d8b --- /dev/null +++ b/tests/lib/outputs/compliance/universal/universal_table_config_requirements_test.py @@ -0,0 +1,155 @@ +"""Integration coverage for ConfigRequirements in the console table generators. + +The table generators aggregate pass/fail counts, so a requirement whose config +is too loose must count its (otherwise PASS) finding as FAIL. Driven through the +universal table renderer, which backs the table output for every framework using +the shared ``get_effective_status`` helper.""" + +from types import SimpleNamespace +from unittest.mock import patch + +from prowler.lib.check.compliance_models import ( + ComplianceFramework, + OutputsConfig, + TableConfig, + UniversalComplianceRequirement, +) +from prowler.lib.outputs.compliance.universal.universal_table import get_universal_table + +_MODULE = "prowler.providers.common.provider.Provider.get_global_provider" +_CHECK = "securityhub_enabled" + + +def _finding(status="PASS"): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=_CHECK), status=status, muted=False + ) + + +# The overview table is only printed when there is more than one finding, so the +# tests use two PASS findings (both mapping the constrained check). +_FINDINGS = [_finding("PASS"), _finding("PASS")] + + +def _framework(): + req = UniversalComplianceRequirement( + id="1.1", + description="region check", + attributes={"Section": "Monitoring"}, + checks={"aws": [_CHECK]}, + config_requirements=[ + { + "Check": _CHECK, + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": False, + } + ], + ) + return ComplianceFramework( + framework="TestFW", + name="Test Framework", + provider="AWS", + version="1.0", + description="Test", + requirements=[req], + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + + +def _render(audit_config, capsys, output_directory): + with patch(_MODULE) as mock_gp: + mock_gp.return_value.audit_config = audit_config + get_universal_table( + findings=_FINDINGS, + bulk_checks_metadata={}, + compliance_framework_name="testfw_1.0_aws", + output_filename="out", + output_directory=str(output_directory), + compliance_overview=False, + framework=_framework(), + provider="aws", + ) + return capsys.readouterr().out + + +class Test_Universal_Table_Config_Requirements: + def test_violating_config_counts_pass_finding_as_fail(self, capsys, tmp_path): + out = _render({"mute_non_default_regions": True}, capsys, tmp_path) + assert "FAIL(2)" in out + assert "PASS(2)" not in out + + def test_valid_config_keeps_pass_count(self, capsys, tmp_path): + out = _render({"mute_non_default_regions": False}, capsys, tmp_path) + assert "PASS(2)" in out + assert "FAIL(2)" not in out + + def test_absent_config_keeps_pass_count(self, capsys, tmp_path): + out = _render({}, capsys, tmp_path) + assert "PASS(2)" in out + assert "FAIL(2)" not in out + + +def _framework_two_requirements(): + """Same check evidences two requirements; only one carries a guardrail. + + Drives the double-count scenario: with the config violated, the shared + finding is FAIL for the constrained requirement and PASS for the other, so + its index would land in both pass and fail counts without FAIL precedence. + """ + constrained = UniversalComplianceRequirement( + id="1.1", + description="region check", + attributes={"Section": "Monitoring"}, + checks={"aws": [_CHECK]}, + config_requirements=[ + { + "Check": _CHECK, + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": False, + } + ], + ) + unconstrained = UniversalComplianceRequirement( + id="2.1", + description="other check", + attributes={"Section": "Logging"}, + checks={"aws": [_CHECK]}, + ) + return ComplianceFramework( + framework="TestFW", + name="Test Framework", + provider="AWS", + version="1.0", + description="Test", + requirements=[constrained, unconstrained], + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + + +class Test_Universal_Table_Multi_Requirement_Dedup: + def test_finding_in_two_requirements_counted_once_with_fail_precedence( + self, capsys, tmp_path + ): + # mute=True violates the constrained requirement → each shared PASS + # finding must be counted once as FAIL in the overview, not double + # counted as both PASS and FAIL across the two requirements it maps. + with patch(_MODULE) as mock_gp: + mock_gp.return_value.audit_config = {"mute_non_default_regions": True} + mock_gp.return_value.type = "aws" + get_universal_table( + findings=_FINDINGS, + bulk_checks_metadata={}, + compliance_framework_name="testfw_1.0_aws", + output_filename="out", + output_directory=str(tmp_path), + compliance_overview=True, + framework=_framework_two_requirements(), + provider="aws", + ) + out = capsys.readouterr().out + # Two findings, each counted once as FAIL → 100% FAIL, 0 PASS. + assert "(2) FAIL" in out + assert "(0) PASS" in out diff --git a/tests/lib/resource_limit_test.py b/tests/lib/resource_limit_test.py new file mode 100644 index 0000000000..ad2f3a292f --- /dev/null +++ b/tests/lib/resource_limit_test.py @@ -0,0 +1,156 @@ +from prowler.lib.resource_limit import ( + get_resource_scan_limit, + iter_limited_paginator_items, + limit_resources, +) + + +class FakePaginator: + def __init__(self, pages): + self.pages = pages + self.paginate_calls = [] + self.pages_requested = 0 + + def paginate(self, **kwargs): + self.paginate_calls.append(kwargs) + for page in self.pages: + self.pages_requested += 1 + yield page + + +class Test_limit_resources: + def test_no_limit_returns_all_in_order(self): + resources = ["PASS", "FAIL", "PASS"] + + result = list(limit_resources(iter(resources), None)) + + assert result == ["PASS", "FAIL", "PASS"] + + +class Test_iter_limited_paginator_items: + def test_positive_limit_stops_without_page_size(self): + paginator = FakePaginator( + [ + {"Items": [1, 2]}, + {"Items": [3, 4]}, + {"Items": [5]}, + ] + ) + + result = list(iter_limited_paginator_items(paginator, "Items", 3)) + + assert result == [1, 2, 3] + assert paginator.paginate_calls == [{}] + assert paginator.pages_requested == 2 + + def test_absurd_limit_is_not_sent_as_page_size(self): + paginator = FakePaginator([{"Items": [1, 2]}]) + + result = list(iter_limited_paginator_items(paginator, "Items", 200000)) + + assert result == [1, 2] + assert paginator.paginate_calls == [{}] + + def test_operation_parameters_are_forwarded_unchanged(self): + paginator = FakePaginator([{"Snapshots": ["snapshot"]}]) + + result = list( + iter_limited_paginator_items( + paginator, + "Snapshots", + 1, + OwnerIds=["self"], + ) + ) + + assert result == ["snapshot"] + assert paginator.paginate_calls == [{"OwnerIds": ["self"]}] + + def test_item_filter_limits_selected_items_only(self): + paginator = FakePaginator( + [ + {"Items": [{"arn": "skip"}, {"arn": "first"}]}, + {"Items": [{"arn": "second"}, {"arn": "third"}]}, + ] + ) + + result = list( + iter_limited_paginator_items( + paginator, + "Items", + 2, + item_filter=lambda item: item["arn"] != "skip", + ) + ) + + assert result == [{"arn": "first"}, {"arn": "second"}] + assert paginator.pages_requested == 2 + + def test_limit_zero_or_negative_is_unlimited(self): + resources = list(range(5)) + + assert list(limit_resources(iter(resources), 0)) == resources + assert list(limit_resources(iter(resources), -3)) == resources + + def test_positive_limit_stops_after_selected_resources(self): + pulled = [] + + def gen(): + for i in range(1000): + pulled.append(i) + yield i + + result = list(limit_resources(gen(), 100)) + + assert result == list(range(100)) + assert len(pulled) == 100 + + def test_does_not_reorder_or_inspect_resource_status(self): + resources = ["PASS", "FAIL", "PASS", "FAIL"] + + result = list(limit_resources(iter(resources), 3)) + + assert result == ["PASS", "FAIL", "PASS"] + + +class Test_get_resource_scan_limit: + def test_per_service_override_wins(self): + config = { + "max_scanned_resources_per_service": 100, + "max_ecs_task_definitions": 25, + } + assert get_resource_scan_limit(config, "max_ecs_task_definitions") == 25 + + def test_falls_back_to_global_default(self): + config = {"max_scanned_resources_per_service": 50} + assert get_resource_scan_limit(config, "max_ecs_task_definitions") == 50 + + def test_null_per_service_override_falls_back_to_global_default(self): + config = { + "max_scanned_resources_per_service": 50, + "max_ecs_task_definitions": None, + } + + assert get_resource_scan_limit(config, "max_ecs_task_definitions") == 50 + + def test_default_is_unlimited_when_unset(self): + assert get_resource_scan_limit({}, "max_ecs_task_definitions") is None + + def test_null_per_service_override_falls_back_to_unlimited_global_default(self): + config = {"max_ecs_task_definitions": None} + + assert get_resource_scan_limit(config, "max_ecs_task_definitions") is None + + def test_non_positive_means_unlimited(self): + assert ( + get_resource_scan_limit( + {"max_scanned_resources_per_service": 0}, "max_lambda_functions" + ) + is None + ) + assert ( + get_resource_scan_limit( + {"max_lambda_functions": -1}, "max_lambda_functions" + ) + is None + ) diff --git a/tests/lib/utils/utils_test.py b/tests/lib/utils/utils_test.py index c8db5a62d0..c3340704de 100644 --- a/tests/lib/utils/utils_test.py +++ b/tests/lib/utils/utils_test.py @@ -1,4 +1,5 @@ import os +import subprocess import tempfile from datetime import datetime from time import mktime @@ -7,7 +8,8 @@ import pytest from mock import patch from prowler.lib.utils.utils import ( - detect_secrets_scan, + SecretsScanError, + detect_secrets_scan_batch, file_exists, get_file_permissions, hash_sha512, @@ -20,6 +22,95 @@ from prowler.lib.utils.utils import ( ) +def _fake_kingfisher_run(output_content=None, returncode=0, stderr=""): + """Build a ``subprocess.run`` replacement that mimics a Kingfisher call. + + When ``output_content`` is given it is written to the ``--output`` path from + the command (so the reader sees realistic file content); the call returns a + CompletedProcess with the requested ``returncode``/``stderr``. + """ + + def _run(command, *_args, **_kwargs): + if output_content is not None: + output_path = command[command.index("--output") + 1] + with open(output_path, "w") as output_file: + output_file.write(output_content) + return subprocess.CompletedProcess( + command, returncode, stdout="", stderr=stderr + ) + + return _run + + +def _fake_kingfisher_run_with_findings(findings): + """Build a ``subprocess.run`` replacement that emits crafted findings. + + Each entry in ``findings`` is a ``(payload_index, line)`` pair: the finding + is mapped back to the temp file named ``str(payload_index)`` (the basename + ``_scan_batch_chunk`` writes per payload) and given the requested ``line`` + value (omitted entirely when ``line`` is the sentinel ``_OMIT``). Returns a + success exit code so only the finding shape is under test. + """ + + def _run(command, *_args, **_kwargs): + output_path = command[command.index("--output") + 1] + entries = [] + for payload_index, line in findings: + finding = {"path": str(payload_index), "snippet": "secret"} + if line is not _OMIT: + finding["line"] = line + entries.append({"finding": finding, "rule": {"name": "Generic Secret"}}) + import json as _json + + with open(output_path, "w") as output_file: + output_file.write(_json.dumps({"findings": entries})) + return subprocess.CompletedProcess(command, 200, stdout="", stderr="") + + return _run + + +_OMIT = object() + + +class Test_detect_secrets_scan_batch_invalid_line: + """Kingfisher's ``line`` is consumed as a trusted 1-based index by checks + (e.g. CloudWatch ``events[line_number - 1]``). A malformed line must fail + closed as SecretsScanError, never return a finding with a bad index.""" + + @pytest.mark.parametrize( + "line", + [_OMIT, None, "2", 0, -1, 5, True], + ids=["missing", "none", "string", "zero", "negative", "out_of_range", "bool"], + ) + def test_invalid_line_raises(self, line): + # Payload "data" is a single line, so any line other than 1 is invalid. + with patch( + "prowler.lib.utils.utils.subprocess.run", + side_effect=_fake_kingfisher_run_with_findings([(0, line)]), + ): + with pytest.raises(SecretsScanError) as exc: + detect_secrets_scan_batch({"a": "data"}) + assert "invalid line number" in str(exc.value) + + def test_valid_line_is_returned(self): + # A valid in-range line must still pass through to the caller. + with patch( + "prowler.lib.utils.utils.subprocess.run", + side_effect=_fake_kingfisher_run_with_findings([(0, 1)]), + ): + results = detect_secrets_scan_batch({"a": "data"}) + assert results["a"][0]["line_number"] == 1 + + def test_one_invalid_line_aborts_the_whole_scan(self): + # Even mixed with a valid finding, a single invalid line fails closed. + with patch( + "prowler.lib.utils.utils.subprocess.run", + side_effect=_fake_kingfisher_run_with_findings([(0, 1), (1, 0)]), + ): + with pytest.raises(SecretsScanError): + detect_secrets_scan_batch({"a": "data", "b": "data"}) + + class Test_utils_open_file: def test_open_read_file(self): temp_data_file = tempfile.NamedTemporaryFile(delete=False) @@ -108,75 +199,108 @@ class Test_utils_validate_ip_address: assert not validate_ip_address("Not an IP") -class Test_detect_secrets_scan: - def test_detect_secrets_scan_data(self): - data = "password=password" - secrets_detected = detect_secrets_scan(data=data, excluded_secrets=[]) - assert type(secrets_detected) is list - assert len(secrets_detected) == 1 - assert "filename" in secrets_detected[0] - assert "hashed_secret" in secrets_detected[0] - assert "is_verified" in secrets_detected[0] - assert secrets_detected[0]["line_number"] == 1 - assert secrets_detected[0]["type"] == "Secret Keyword" - - def test_detect_secrets_scan_no_secrets_data(self): - data = "" - assert detect_secrets_scan(data=data) is None - - def test_detect_secrets_scan_file_with_secrets(self): - temp_data_file = tempfile.NamedTemporaryFile(delete=False) - temp_data_file.write(b"password=password") - temp_data_file.seek(0) - secrets_detected = detect_secrets_scan( - file=temp_data_file.name, excluded_secrets=[] +class Test_detect_secrets_scan_batch: + def test_batch_returns_findings_per_key(self): + results = detect_secrets_scan_batch( + { + "a": 'password = "Tr0ub4dor3xKq9vLmZ"', + "b": "just a normal config = value", + } ) - assert type(secrets_detected) is list - assert len(secrets_detected) == 1 - assert "filename" in secrets_detected[0] - assert "hashed_secret" in secrets_detected[0] - assert "is_verified" in secrets_detected[0] - assert secrets_detected[0]["line_number"] == 1 - assert secrets_detected[0]["type"] == "Secret Keyword" - os.remove(temp_data_file.name) + assert "a" in results + assert results["a"][0]["type"] == "Generic Password" + # keys without findings are omitted + assert "b" not in results - def test_detect_secrets_scan_file_no_secrets(self): - temp_data_file = tempfile.NamedTemporaryFile(delete=False) - temp_data_file.write(b"no secrets") - temp_data_file.seek(0) - assert detect_secrets_scan(file=temp_data_file.name) is None - os.remove(temp_data_file.name) + def test_batch_no_dedup_reports_identical_secret_in_each_key(self): + # The same secret in two payloads must be reported for both (matches + # scanning each payload individually). + secret = "token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + results = detect_secrets_scan_batch({"a": secret, "b": secret}) + assert "a" in results + assert "b" in results - def test_detect_secrets_using_regex(self): - data = "MYSQL_ALLOW_EMPTY_PASSWORD=password" - secrets_detected = detect_secrets_scan( - data=data, excluded_secrets=[".*password"] + def test_batch_excluded_secrets_filters(self): + results = detect_secrets_scan_batch( + {"a": 'DB_ALLOW_EMPTY_PASSWORD = "Tr0ub4dor3xKq9vLmZ"'}, + excluded_secrets=[".*ALLOW_EMPTY_PASSWORD.*"], ) - assert secrets_detected is None + assert results == {} - def test_detect_secrets_using_regex_file(self): - temp_data_file = tempfile.NamedTemporaryFile(delete=False) - temp_data_file.write(b"MYSQL_ALLOW_EMPTY_PASSWORD=password") - temp_data_file.seek(0) - secrets_detected = detect_secrets_scan( - file=temp_data_file.name, excluded_secrets=[".*password"] - ) - assert secrets_detected is None - os.remove(temp_data_file.name) + def test_batch_chunking_maps_all_keys(self): + payloads = {f"k{i}": f'password = "S3cr3tV4lu3xy{i}z"' for i in range(5)} + results = detect_secrets_scan_batch(payloads, chunk_size=2) + assert sorted(results.keys()) == ["k0", "k1", "k2", "k3", "k4"] - def test_detect_secrets_secrets_using_regex(self): - data = "MYSQL_ALLOW_EMPTY_PASSWORD=password, MYSQL_PASSWORD=password" - # Update the regex to exclude only the exact key "MYSQL_ALLOW_EMPTY_PASSWORD" - secrets_detected = detect_secrets_scan( - data=data, excluded_secrets=["^MYSQL_ALLOW_EMPTY_PASSWORD$"] + def test_batch_empty_payloads(self): + assert detect_secrets_scan_batch({}) == {} + + def test_batch_accepts_iterable_of_pairs(self): + results = detect_secrets_scan_batch( + iter([("x", 'password = "Tr0ub4dor3xKq9vLmZ"')]) ) - assert type(secrets_detected) is list - assert len(secrets_detected) == 1 - assert "filename" in secrets_detected[0] - assert "hashed_secret" in secrets_detected[0] - assert "is_verified" in secrets_detected[0] - assert secrets_detected[0]["line_number"] == 1 - assert secrets_detected[0]["type"] == "Secret Keyword" + assert "x" in results + + +class Test_detect_secrets_scan_batch_failures: + """A scanner failure must surface as SecretsScanError, never as empty + results (which a caller would read as 'no secrets found').""" + + def test_non_zero_exit_code_raises(self): + with patch( + "prowler.lib.utils.utils.subprocess.run", + side_effect=_fake_kingfisher_run(returncode=1, stderr="boom"), + ): + with pytest.raises(SecretsScanError) as exc: + detect_secrets_scan_batch({"a": "data"}) + assert "exited with code 1" in str(exc.value) + assert "boom" in str(exc.value) + + def test_timeout_raises(self): + with patch( + "prowler.lib.utils.utils.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="kingfisher", timeout=300), + ): + with pytest.raises(SecretsScanError) as exc: + detect_secrets_scan_batch({"a": "data"}) + assert "timed out" in str(exc.value) + + def test_malformed_json_output_raises(self): + with patch( + "prowler.lib.utils.utils.subprocess.run", + side_effect=_fake_kingfisher_run( + output_content="{not valid json", returncode=0 + ), + ): + with pytest.raises(SecretsScanError): + detect_secrets_scan_batch({"a": "data"}) + + def test_missing_binary_raises(self): + with patch( + "prowler.lib.utils.utils.subprocess.run", + side_effect=FileNotFoundError("kingfisher binary not found"), + ): + with pytest.raises(SecretsScanError): + detect_secrets_scan_batch({"a": "data"}) + + def test_empty_output_is_not_a_failure(self): + # Empty output means the scan ran and found nothing; it must NOT raise. + with patch( + "prowler.lib.utils.utils.subprocess.run", + side_effect=_fake_kingfisher_run(output_content="", returncode=0), + ): + assert detect_secrets_scan_batch({"a": "data"}) == {} + + def test_failure_in_any_chunk_aborts_the_whole_scan(self): + # A failure in any chunk must abort the whole scan, not silently return + # partial results from the chunks that happened to succeed first. + payloads = {f"k{i}": "data" for i in range(4)} + with patch( + "prowler.lib.utils.utils.subprocess.run", + side_effect=_fake_kingfisher_run(returncode=2, stderr="boom"), + ): + with pytest.raises(SecretsScanError): + detect_secrets_scan_batch(payloads, chunk_size=2) class Test_hash_sha512: diff --git a/tests/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables_test.py b/tests/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables_test.py new file mode 100644 index 0000000000..8afb7b4fc8 --- /dev/null +++ b/tests/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables_test.py @@ -0,0 +1,325 @@ +from unittest import mock + +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + + +class Test_apigateway_restapi_no_secrets_in_stage_variables: + @mock_aws + def test_no_rest_apis(self): + from prowler.providers.aws.services.apigateway.apigateway_service import ( + APIGateway, + ) + + 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.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client", + new=APIGateway(aws_provider), + ), + ): + from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import ( + apigateway_restapi_no_secrets_in_stage_variables, + ) + + check = apigateway_restapi_no_secrets_in_stage_variables() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_stage_with_no_variables(self): + apigw = client("apigateway", region_name=AWS_REGION_US_EAST_1) + rest_api = apigw.create_rest_api(name="test-api") + api_id = rest_api["id"] + + root_id = apigw.get_resources(restApiId=api_id)["items"][0]["id"] + resource = apigw.create_resource( + restApiId=api_id, parentId=root_id, pathPart="test" + ) + apigw.put_method( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="GET", + authorizationType="NONE", + ) + apigw.put_integration( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="GET", + type="HTTP", + integrationHttpMethod="POST", + uri="http://test.com", + ) + apigw.create_deployment(restApiId=api_id, stageName="prod") + + from prowler.providers.aws.services.apigateway.apigateway_service import ( + APIGateway, + ) + + 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.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client", + new=APIGateway(aws_provider), + ), + ): + from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import ( + apigateway_restapi_no_secrets_in_stage_variables, + ) + + check = apigateway_restapi_no_secrets_in_stage_variables() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "No secrets found in stage variables of API Gateway " + "REST API test-api stage prod." + ) + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == "test-api/prod" + + @mock_aws + def test_stage_with_safe_variables(self): + apigw = client("apigateway", region_name=AWS_REGION_US_EAST_1) + rest_api = apigw.create_rest_api(name="test-api") + api_id = rest_api["id"] + + root_id = apigw.get_resources(restApiId=api_id)["items"][0]["id"] + resource = apigw.create_resource( + restApiId=api_id, parentId=root_id, pathPart="test" + ) + apigw.put_method( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="GET", + authorizationType="NONE", + ) + apigw.put_integration( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="GET", + type="HTTP", + integrationHttpMethod="POST", + uri="http://test.com", + ) + apigw.create_deployment(restApiId=api_id, stageName="prod") + apigw.update_stage( + restApiId=api_id, + stageName="prod", + patchOperations=[ + { + "op": "replace", + "path": "/variables/environment", + "value": "production", + }, + {"op": "replace", "path": "/variables/region", "value": "us-east-1"}, + ], + ) + + from prowler.providers.aws.services.apigateway.apigateway_service import ( + APIGateway, + ) + + 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.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client", + new=APIGateway(aws_provider), + ), + ): + from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import ( + apigateway_restapi_no_secrets_in_stage_variables, + ) + + check = apigateway_restapi_no_secrets_in_stage_variables() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "No secrets found in stage variables of API Gateway " + "REST API test-api stage prod." + ) + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == "test-api/prod" + + @mock_aws + def test_stage_with_secrets_in_variables(self): + apigw = client("apigateway", region_name=AWS_REGION_US_EAST_1) + rest_api = apigw.create_rest_api(name="test-api") + api_id = rest_api["id"] + + root_id = apigw.get_resources(restApiId=api_id)["items"][0]["id"] + resource = apigw.create_resource( + restApiId=api_id, parentId=root_id, pathPart="test" + ) + apigw.put_method( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="GET", + authorizationType="NONE", + ) + apigw.put_integration( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="GET", + type="HTTP", + integrationHttpMethod="POST", + uri="http://test.com", + ) + apigw.create_deployment(restApiId=api_id, stageName="prod") + # A safe variable is added alongside the secret so the secret is not the + # only variable present. This guards the line-number -> variable-name + # mapping against an off-by-one that would otherwise still point at the + # single variable and pass unnoticed. + # A syntactically valid JSON Web Token that Kingfisher flags as a secret. + apigw.update_stage( + restApiId=api_id, + stageName="prod", + patchOperations=[ + { + "op": "replace", + "path": "/variables/environment", + "value": "production", + }, + { + "op": "replace", + "path": "/variables/api_token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", + }, + ], + ) + + from prowler.providers.aws.services.apigateway.apigateway_service import ( + APIGateway, + ) + + 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.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client", + new=APIGateway(aws_provider), + ), + ): + from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import ( + apigateway_restapi_no_secrets_in_stage_variables, + ) + + check = apigateway_restapi_no_secrets_in_stage_variables() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "test-api" in result[0].status_extended + assert "prod" in result[0].status_extended + assert "in variable api_token" in result[0].status_extended + # The secret must be attributed to the correct variable, not the + # safe one that precedes it. + assert "in variable environment" not in result[0].status_extended + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == "test-api/prod" + + @mock_aws + def test_stage_with_variables_scan_error(self): + apigw = client("apigateway", region_name=AWS_REGION_US_EAST_1) + rest_api = apigw.create_rest_api(name="test-api") + api_id = rest_api["id"] + + root_id = apigw.get_resources(restApiId=api_id)["items"][0]["id"] + resource = apigw.create_resource( + restApiId=api_id, parentId=root_id, pathPart="test" + ) + apigw.put_method( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="GET", + authorizationType="NONE", + ) + apigw.put_integration( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="GET", + type="HTTP", + integrationHttpMethod="POST", + uri="http://test.com", + ) + apigw.create_deployment(restApiId=api_id, stageName="prod") + apigw.update_stage( + restApiId=api_id, + stageName="prod", + patchOperations=[ + {"op": "replace", "path": "/variables/api_token", "value": "value"}, + ], + ) + + from prowler.lib.utils.utils import SecretsScanError + from prowler.providers.aws.services.apigateway.apigateway_service import ( + APIGateway, + ) + + 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.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client", + new=APIGateway(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.detect_secrets_scan_batch", + side_effect=SecretsScanError("Kingfisher failed"), + ), + ): + from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import ( + apigateway_restapi_no_secrets_in_stage_variables, + ) + + check = apigateway_restapi_no_secrets_in_stage_variables() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "manual review is required" in result[0].status_extended + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == "test-api/prod" diff --git a/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration_test.py b/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration_test.py index 1005761834..ec45b89a49 100644 --- a/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration_test.py +++ b/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration_test.py @@ -104,7 +104,7 @@ class Test_autoscaling_find_secrets_ec2_launch_configuration: InstanceType="t1.micro", KeyName="the_keys", SecurityGroups=["default", "default2"], - UserData="DB_PASSWORD=foobar123", + UserData='DB_PASSWORD="Tr0ub4dor3xKq9vLmZ"', ) launch_configuration_arn = autoscaling_client.describe_launch_configurations( LaunchConfigurationNames=[launch_configuration_name] @@ -341,7 +341,9 @@ class Test_autoscaling_find_secrets_ec2_launch_configuration: check = autoscaling_find_secrets_ec2_launch_configuration() result = check.execute() - assert len(result) == 0 + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not decode User Data" in result[0].status_extended @mock_aws def test_one_autoscaling_file_invalid_gzip_error(self): @@ -381,4 +383,6 @@ class Test_autoscaling_find_secrets_ec2_launch_configuration: check = autoscaling_find_secrets_ec2_launch_configuration() result = check.execute() - assert len(result) == 0 + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not decode User Data" in result[0].status_extended diff --git a/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/fixtures/fixture b/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/fixtures/fixture index 2fb5138932..c591954ab4 100644 --- a/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/fixtures/fixture +++ b/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/fixtures/fixture @@ -1,4 +1,4 @@ -DB_PASSWORD=foobar123 +DB_PASSWORD="Tr0ub4dor3xKq9vLmZ" DB_USER=foo -API_KEY=12345abcd -SERVICE_PASSWORD=bbaabb45 +API_KEY=s3rv1c3Acc0untS3cr3tV4lu3x9 +SERVICE_PASSWORD="Xy9zPq2wKmRtVbN4" diff --git a/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/fixtures/fixture.gz b/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/fixtures/fixture.gz index 6120fcfbc4..15e68af70e 100644 Binary files a/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/fixtures/fixture.gz and b/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/fixtures/fixture.gz differ diff --git a/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code_test.py b/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code_test.py index 5f97082c1b..a68724da7f 100644 --- a/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code_test.py +++ b/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code_test.py @@ -1,3 +1,4 @@ +import os import zipfile from unittest import mock @@ -19,7 +20,7 @@ LAMBDA_FUNCTION_RUNTIME = "nodejs4.3" LAMBDA_FUNCTION_ARN = f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:function/{LAMBDA_FUNCTION_NAME}" LAMBDA_FUNCTION_CODE_WITH_SECRETS = """ def lambda_handler(event, context): - db_password = "test-password" + db_password = "Tr0ub4dor3xKq9vLmZ" print("custom log event") return event """ @@ -53,6 +54,57 @@ def get_lambda_code_with_secrets(code): ) +LAMBDA_DEPS_JSON_WITH_SECRET = """ +{ + "runtimeTarget": { "name": ".NETCoreApp,Version=v8.0" }, + "libraries": { + "AWSSDK.SecretsManager/3.7.0": { + "type": "package", + "password": "test-deps-json-password" + } + } +} +""" + +LAMBDA_VENDOR_JS_WITH_SECRET = """ +const dbPassword = "test-vendor-password"; +""" + + +def get_lambda_code_from_files(files: dict) -> LambdaCode: + # The check only calls code_zip.extractall(dir); mock it to drop the + # given files into the temporary directory the check creates, so no + # real archive needs to be built. + code_zip = mock.MagicMock() + + def _extractall(path): + for name, content in files.items(): + os.makedirs(os.path.dirname(f"{path}/{name}"), exist_ok=True) + with open(f"{path}/{name}", "w") as fd: + fd.write(content) + + code_zip.extractall.side_effect = _extractall + return LambdaCode(location="", code_zip=code_zip) + + +def mock_get_function_code_with_deps_json_secret(): + yield create_lambda_function(), get_lambda_code_from_files( + { + "lambda_function.py": LAMBDA_FUNCTION_CODE_WITHOUT_SECRETS, + "myapp.deps.json": LAMBDA_DEPS_JSON_WITH_SECRET, + } + ) + + +def mock_get_function_code_with_nested_vendor_secret(): + yield create_lambda_function(), get_lambda_code_from_files( + { + "lambda_function.py": LAMBDA_FUNCTION_CODE_WITHOUT_SECRETS, + "vendor/package.js": LAMBDA_VENDOR_JS_WITH_SECRET, + } + ) + + def mock_get_function_codewith_secrets(): yield create_lambda_function(), get_lambda_code_with_secrets( LAMBDA_FUNCTION_CODE_WITH_SECRETS @@ -126,7 +178,7 @@ class Test_awslambda_function_no_secrets_in_code: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in Lambda function {LAMBDA_FUNCTION_NAME} code -> lambda_function.py: Secret Keyword on line 3." + == f"Potential secret found in Lambda function {LAMBDA_FUNCTION_NAME} code -> lambda_function.py: Generic Password on line 3." ) assert result[0].resource_tags == [] @@ -201,3 +253,163 @@ class Test_awslambda_function_no_secrets_in_code: == f"No secrets found in Lambda function {LAMBDA_FUNCTION_NAME} code." ) assert result[0].resource_tags == [] + + def test_function_code_deps_json_secret_not_ignored(self): + lambda_client = mock.MagicMock + lambda_client.functions = {LAMBDA_FUNCTION_ARN: create_lambda_function()} + lambda_client._get_function_code = mock_get_function_code_with_deps_json_secret + lambda_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_ignore_files": None, + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code.awslambda_client", + new=lambda_client, + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code import ( + awslambda_function_no_secrets_in_code, + ) + + check = awslambda_function_no_secrets_in_code() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "myapp.deps.json" in result[0].status_extended + + def test_function_code_nested_vendor_secret_not_ignored(self): + lambda_client = mock.MagicMock + lambda_client.functions = {LAMBDA_FUNCTION_ARN: create_lambda_function()} + lambda_client._get_function_code = ( + mock_get_function_code_with_nested_vendor_secret + ) + lambda_client.audit_config = {"secrets_ignore_patterns": []} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code.awslambda_client", + new=lambda_client, + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code import ( + awslambda_function_no_secrets_in_code, + ) + + check = awslambda_function_no_secrets_in_code() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "vendor/package.js" in result[0].status_extended + + def test_function_code_nested_vendor_secret_ignored_by_file_pattern(self): + lambda_client = mock.MagicMock + lambda_client.functions = {LAMBDA_FUNCTION_ARN: create_lambda_function()} + lambda_client._get_function_code = ( + mock_get_function_code_with_nested_vendor_secret + ) + lambda_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_ignore_files": ["vendor/*.js"], + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code.awslambda_client", + new=lambda_client, + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code import ( + awslambda_function_no_secrets_in_code, + ) + + check = awslambda_function_no_secrets_in_code() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_function_code_deps_json_secret_ignored_by_file_pattern(self): + lambda_client = mock.MagicMock + lambda_client.functions = {LAMBDA_FUNCTION_ARN: create_lambda_function()} + lambda_client._get_function_code = mock_get_function_code_with_deps_json_secret + lambda_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_ignore_files": ["*.deps.json"], + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code.awslambda_client", + new=lambda_client, + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code import ( + awslambda_function_no_secrets_in_code, + ) + + check = awslambda_function_no_secrets_in_code() + result = check.execute() + + assert len(result) == 1 + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == LAMBDA_FUNCTION_NAME + assert result[0].resource_arn == LAMBDA_FUNCTION_ARN + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"No secrets found in Lambda function {LAMBDA_FUNCTION_NAME} code." + ) + assert result[0].resource_tags == [] + + def test_scan_failure_reports_manual_not_pass(self): + from prowler.lib.utils.utils import SecretsScanError + + lambda_client = mock.MagicMock + lambda_client.functions = {LAMBDA_FUNCTION_ARN: create_lambda_function()} + lambda_client._get_function_code = mock_get_function_codewith_secrets + lambda_client.audit_config = {"secrets_ignore_patterns": []} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code.awslambda_client", + new=lambda_client, + ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code.detect_secrets_scan_batch", + side_effect=SecretsScanError("Kingfisher exited with code 1"), + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code import ( + awslambda_function_no_secrets_in_code, + ) + + check = awslambda_function_no_secrets_in_code() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not scan" in result[0].status_extended diff --git a/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables_test.py b/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables_test.py index 6ae517dfe2..f7ba14a7d3 100644 --- a/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables_test.py +++ b/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables_test.py @@ -97,7 +97,7 @@ class Test_awslambda_function_no_secrets_in_variables: arn=function_arn, region=AWS_REGION_US_EAST_1, runtime=function_runtime, - environment={"db_password": "test-password"}, + environment={"db_password": "Tr0ub4dor3xKq9vLmZ"}, ) } @@ -126,7 +126,7 @@ class Test_awslambda_function_no_secrets_in_variables: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in Lambda function {function_name} variables -> Secret Keyword in variable db_password." + == f"Potential secret found in Lambda function {function_name} variables -> Generic Password in variable db_password." ) assert result[0].resource_tags == [] @@ -145,7 +145,69 @@ class Test_awslambda_function_no_secrets_in_variables: arn=function_arn, region=AWS_REGION_US_EAST_1, runtime=function_runtime, - environment={"db_password": "srv://admin:pass@db"}, + environment={ + "db_password": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + }, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables.awslambda_client", + new=lambda_client, + ), + ): + # Test Check + from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables import ( + awslambda_function_no_secrets_in_variables, + ) + + check = awslambda_function_no_secrets_in_variables() + result = check.execute() + + assert len(result) == 1 + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == function_name + assert result[0].resource_arn == function_arn + assert result[0].status == "FAIL" + # Kingfisher reports both the generic keyword rule and the JWT rule + # for the same value; their order is not guaranteed, so assert on + # presence rather than a fixed concatenation order. + assert result[0].status_extended.startswith( + f"Potential secret found in Lambda function {function_name} variables -> " + ) + assert ( + "Generic Password in variable db_password" in result[0].status_extended + ) + assert ( + "JSON Web Token (base64url-encoded) in variable db_password" + in result[0].status_extended + ) + assert result[0].resource_tags == [] + + def test_function_secrets_in_variables_telegram_token(self): + lambda_client = mock.MagicMock + function_name = "test-lambda" + function_runtime = "nodejs4.3" + function_arn = f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:function/{function_name}" + lambda_client.audit_config = {"secrets_ignore_patterns": []} + lambda_client.functions = { + "function_name": Function( + name=function_name, + security_groups=[], + arn=function_arn, + region=AWS_REGION_US_EAST_1, + runtime=function_runtime, + environment={ + # The Telegram bot-token rule is no longer enabled in + # Kingfisher's built-in ruleset, so a detectable JWT + # is used to keep this token-in-variable case meaningful. + "TELEGRAM_BOT_TOKEN": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + }, ) } @@ -174,16 +236,22 @@ class Test_awslambda_function_no_secrets_in_variables: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in Lambda function {function_name} variables -> Secret Keyword in variable db_password, Basic Auth Credentials in variable db_password." + == f"Potential secret found in Lambda function {function_name} variables -> JSON Web Token (base64url-encoded) in variable TELEGRAM_BOT_TOKEN." ) assert result[0].resource_tags == [] - def test_function_secrets_in_variables_telegram_token(self): + def test_function_with_verified_secret(self): + from prowler.lib.check.models import Severity + lambda_client = mock.MagicMock function_name = "test-lambda" function_runtime = "nodejs4.3" function_arn = f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:function/{function_name}" - lambda_client.audit_config = {"secrets_ignore_patterns": []} + lambda_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_validate": True, + } + lambda_client.functions = { "function_name": Function( name=function_name, @@ -191,19 +259,35 @@ class Test_awslambda_function_no_secrets_in_variables: arn=function_arn, region=AWS_REGION_US_EAST_1, runtime=function_runtime, - environment={"TELEGRAM_BOT_TOKEN": "telegram-token"}, + environment={"db_password": "test-value"}, ) } with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_aws_provider(), + return_value=set_mocked_aws_provider( + audit_config={"secrets_validate": True} + ), ), mock.patch( "prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables.awslambda_client", new=lambda_client, ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables.detect_secrets_scan_batch", + return_value={ + 0: [ + { + "type": "JSON Web Token (base64url-encoded)", + "line_number": 2, + "filename": "data", + "hashed_secret": "x", + "is_verified": True, + } + ] + }, + ) as mock_scan, ): # Test Check from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables import ( @@ -213,16 +297,13 @@ class Test_awslambda_function_no_secrets_in_variables: check = awslambda_function_no_secrets_in_variables() result = check.execute() + # The check must forward secrets_validate from the config to the scan. + assert mock_scan.call_args.kwargs.get("validate") is True assert len(result) == 1 - assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "confirmed to be live" in result[0].status_extended assert result[0].resource_id == function_name - assert result[0].resource_arn == function_arn - assert result[0].status == "PASS" - assert ( - result[0].status_extended - == f"No secrets found in Lambda function {function_name} variables." - ) - assert result[0].resource_tags == [] def test_function_no_secrets_in_variables(self): lambda_client = mock.MagicMock @@ -270,3 +351,48 @@ class Test_awslambda_function_no_secrets_in_variables: == f"No secrets found in Lambda function {function_name} variables." ) assert result[0].resource_tags == [] + + def test_scan_failure_reports_manual_not_pass(self): + # A scanner failure must not be treated as "no secrets found". + from prowler.lib.utils.utils import SecretsScanError + + lambda_client = mock.MagicMock + function_name = "test-lambda" + function_arn = f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:function/{function_name}" + lambda_client.audit_config = {"secrets_ignore_patterns": []} + lambda_client.functions = { + "function_name": Function( + name=function_name, + security_groups=[], + arn=function_arn, + region=AWS_REGION_US_EAST_1, + runtime="nodejs4.3", + environment={"db_password": "test-value"}, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables.awslambda_client", + new=lambda_client, + ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables.detect_secrets_scan_batch", + side_effect=SecretsScanError("Kingfisher exited with code 1"), + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables import ( + awslambda_function_no_secrets_in_variables, + ) + + check = awslambda_function_no_secrets_in_variables() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not scan" in result[0].status_extended + assert "manual review is required" in result[0].status_extended diff --git a/tests/providers/aws/services/awslambda/awslambda_service_test.py b/tests/providers/aws/services/awslambda/awslambda_service_test.py index 412c944d8b..302b99d109 100644 --- a/tests/providers/aws/services/awslambda/awslambda_service_test.py +++ b/tests/providers/aws/services/awslambda/awslambda_service_test.py @@ -6,10 +6,16 @@ from re import search from unittest.mock import patch import mock +import pytest from boto3 import client, resource +from botocore.client import ClientError from moto import mock_aws -from prowler.providers.aws.services.awslambda.awslambda_service import AuthType, Lambda +from prowler.providers.aws.services.awslambda.awslambda_service import ( + AuthType, + Function, + Lambda, +) from tests.providers.aws.utils import ( AWS_ACCOUNT_NUMBER, AWS_REGION_EU_WEST_1, @@ -85,6 +91,367 @@ class Test_Lambda_Service: awslambda = Lambda(set_mocked_aws_provider([AWS_REGION_US_EAST_1])) assert awslambda.service == "lambda" + def test_function_limit_selects_latest_functions_for_analysis(self): + awslambda = Lambda.__new__(Lambda) + awslambda.functions = { + "old": Function( + name="old", + arn="old", + security_groups=[], + last_modified="2024-01-01T00:00:00.000+0000", + region=AWS_REGION_EU_WEST_1, + ), + "new": Function( + name="new", + arn="new", + security_groups=[], + last_modified="2024-01-02T00:00:00.000+0000", + region=AWS_REGION_EU_WEST_1, + ), + } + awslambda.function_limit = 1 + + awslambda._select_functions_for_analysis() + + assert list(awslambda.functions) == ["new"] + + def test_function_limit_selects_global_latest_across_regions(self): + class FakePaginator: + def __init__(self, functions): + self.functions = functions + + def paginate(self, **kwargs): + assert "PageSize" not in kwargs + return [{"Functions": self.functions}] + + class FakeLambdaClient: + def __init__(self, region, functions): + self.region = region + self.functions = functions + + def get_paginator(self, name): + assert name == "list_functions" + return FakePaginator(self.functions) + + awslambda = Lambda.__new__(Lambda) + awslambda.functions = {} + awslambda.security_groups_in_use = set() + awslambda.regions_with_functions = set() + awslambda.function_limit = 1 + awslambda.audit_resources = [] + old_client = FakeLambdaClient( + AWS_REGION_EU_WEST_1, + [ + { + "FunctionName": "old", + "FunctionArn": "arn:aws:lambda:eu-west-1:123456789012:function:old", + "LastModified": "2024-01-01T00:00:00.000+0000", + } + ], + ) + new_client = FakeLambdaClient( + AWS_REGION_US_EAST_1, + [ + { + "FunctionName": "new", + "FunctionArn": "arn:aws:lambda:us-east-1:123456789012:function:new", + "LastModified": "2024-01-02T00:00:00.000+0000", + } + ], + ) + + awslambda._list_functions(old_client) + awslambda._list_functions(new_client) + awslambda._select_functions_for_analysis() + + assert [function.name for function in awslambda.functions.values()] == ["new"] + + def test_function_limit_keeps_complete_auxiliary_indexes(self): + class FakePaginator: + def __init__(self, functions): + self.functions = functions + + def paginate(self, **kwargs): + assert "PageSize" not in kwargs + return [{"Functions": self.functions}] + + class FakeLambdaClient: + region = AWS_REGION_US_EAST_1 + + def get_paginator(self, name): + assert name == "list_functions" + return FakePaginator( + [ + { + "FunctionName": "old", + "FunctionArn": ( + f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:" + f"{AWS_ACCOUNT_NUMBER}:function:old" + ), + "LastModified": "2024-01-01T00:00:00.000+0000", + "VpcConfig": {"SecurityGroupIds": ["sg-old"]}, + }, + { + "FunctionName": "new", + "FunctionArn": ( + f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:" + f"{AWS_ACCOUNT_NUMBER}:function:new" + ), + "LastModified": "2024-01-02T00:00:00.000+0000", + "VpcConfig": {"SecurityGroupIds": ["sg-new"]}, + }, + ] + ) + + awslambda = Lambda.__new__(Lambda) + awslambda.functions = {} + awslambda.security_groups_in_use = set() + awslambda.regions_with_functions = set() + awslambda.function_limit = 1 + awslambda.audit_resources = [] + + awslambda._list_functions(FakeLambdaClient()) + awslambda._select_functions_for_analysis() + + assert [function.name for function in awslambda.functions.values()] == ["new"] + assert awslambda.security_groups_in_use == {"sg-old", "sg-new"} + assert awslambda.regions_with_functions == {AWS_REGION_US_EAST_1} + + def test_list_event_source_mappings_uses_selected_functions_as_api_scope(self): + class FakePaginator: + def __init__(self): + self.paginate_calls = [] + + def paginate(self, **kwargs): + self.paginate_calls.append(kwargs) + function_name = kwargs["FunctionName"] + return [ + { + "EventSourceMappings": [ + { + "UUID": f"{function_name}-mapping", + "FunctionArn": ( + f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:" + f"{AWS_ACCOUNT_NUMBER}:function:{function_name}:1" + ), + "EventSourceArn": "arn:aws:sqs:queue", + "State": "Enabled", + "BatchSize": 10, + } + ] + } + ] + + class FakeLambdaClient: + region = AWS_REGION_US_EAST_1 + + def __init__(self): + self.paginator = FakePaginator() + + def get_paginator(self, name): + assert name == "list_event_source_mappings" + return self.paginator + + selected_arn = ( + f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:" + f"{AWS_ACCOUNT_NUMBER}:function:selected" + ) + other_region_arn = ( + f"arn:aws:lambda:{AWS_REGION_EU_WEST_1}:" + f"{AWS_ACCOUNT_NUMBER}:function:other-region" + ) + awslambda = Lambda.__new__(Lambda) + awslambda.function_limit = 1 + awslambda.functions = { + selected_arn: Function( + name="selected", + arn=selected_arn, + security_groups=[], + region=AWS_REGION_US_EAST_1, + ), + other_region_arn: Function( + name="other-region", + arn=other_region_arn, + security_groups=[], + region=AWS_REGION_EU_WEST_1, + ), + } + regional_client = FakeLambdaClient() + + awslambda._list_event_source_mappings(regional_client) + + assert regional_client.paginator.paginate_calls == [ + {"FunctionName": "selected"} + ] + assert len(awslambda.functions[selected_arn].event_source_mappings) == 1 + assert ( + awslambda.functions[selected_arn].event_source_mappings[0].uuid + == "selected-mapping" + ) + assert not awslambda.functions[other_region_arn].event_source_mappings + + def test_list_event_source_mappings_keeps_unlimited_regional_api_scope(self): + class FakePaginator: + def __init__(self): + self.paginate_calls = [] + + def paginate(self, **kwargs): + self.paginate_calls.append(kwargs) + return [ + { + "EventSourceMappings": [ + { + "UUID": "selected-mapping", + "FunctionArn": selected_arn, + "EventSourceArn": "arn:aws:sqs:queue", + "State": "Enabled", + } + ] + } + ] + + class FakeLambdaClient: + region = AWS_REGION_US_EAST_1 + + def __init__(self): + self.paginator = FakePaginator() + + def get_paginator(self, name): + assert name == "list_event_source_mappings" + return self.paginator + + selected_arn = ( + f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:" + f"{AWS_ACCOUNT_NUMBER}:function:selected" + ) + awslambda = Lambda.__new__(Lambda) + awslambda.function_limit = None + awslambda.functions = { + selected_arn: Function( + name="selected", + arn=selected_arn, + security_groups=[], + region=AWS_REGION_US_EAST_1, + ) + } + regional_client = FakeLambdaClient() + + awslambda._list_event_source_mappings(regional_client) + + assert regional_client.paginator.paginate_calls == [{}] + assert len(awslambda.functions[selected_arn].event_source_mappings) == 1 + + def test_list_event_source_mappings_continues_after_invalid_parameter_value(self): + class FakePaginator: + def paginate(self, **kwargs): + function_name = kwargs["FunctionName"] + if function_name == "deleted": + raise ClientError( + { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Function no longer exists", + } + }, + "ListEventSourceMappings", + ) + return [ + { + "EventSourceMappings": [ + { + "UUID": f"{function_name}-mapping", + "FunctionArn": ( + f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:" + f"{AWS_ACCOUNT_NUMBER}:function:{function_name}" + ), + "EventSourceArn": "arn:aws:sqs:queue", + "State": "Enabled", + } + ] + } + ] + + class FakeLambdaClient: + region = AWS_REGION_US_EAST_1 + + def get_paginator(self, name): + assert name == "list_event_source_mappings" + return FakePaginator() + + deleted_arn = ( + f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:" + f"{AWS_ACCOUNT_NUMBER}:function:deleted" + ) + remaining_arn = ( + f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:" + f"{AWS_ACCOUNT_NUMBER}:function:remaining" + ) + awslambda = Lambda.__new__(Lambda) + awslambda.function_limit = 2 + awslambda.functions = { + deleted_arn: Function( + name="deleted", + arn=deleted_arn, + security_groups=[], + region=AWS_REGION_US_EAST_1, + ), + remaining_arn: Function( + name="remaining", + arn=remaining_arn, + security_groups=[], + region=AWS_REGION_US_EAST_1, + ), + } + + awslambda._list_event_source_mappings(FakeLambdaClient()) + + assert not awslambda.functions[deleted_arn].event_source_mappings + assert len(awslambda.functions[remaining_arn].event_source_mappings) == 1 + assert ( + awslambda.functions[remaining_arn].event_source_mappings[0].uuid + == "remaining-mapping" + ) + + def test_list_event_source_mappings_raises_non_transient_client_error(self): + class FakePaginator: + def paginate(self, **kwargs): + raise ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "Access denied", + } + }, + "ListEventSourceMappings", + ) + + class FakeLambdaClient: + region = AWS_REGION_US_EAST_1 + + def get_paginator(self, name): + assert name == "list_event_source_mappings" + return FakePaginator() + + function_arn = ( + f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:" + f"{AWS_ACCOUNT_NUMBER}:function:selected" + ) + awslambda = Lambda.__new__(Lambda) + awslambda.function_limit = 1 + awslambda.functions = { + function_arn: Function( + name="selected", + arn=function_arn, + security_groups=[], + region=AWS_REGION_US_EAST_1, + ) + } + + with pytest.raises(ClientError) as error: + awslambda._list_event_source_mappings(FakeLambdaClient()) + + assert error.value.response["Error"]["Code"] == "AccessDeniedException" + @mock_aws def test_list_functions(self): # Create IAM Lambda Role @@ -253,3 +620,63 @@ class Test_Lambda_Service: f"{tmp_dir_name}/{files_in_zip[0]}", "r" ) as lambda_code_file: assert lambda_code_file.read() == LAMBDA_FUNCTION_CODE + + @mock_aws + def test_function_limit_exposes_only_selected_functions(self): + lambda_client = client("lambda", region_name=AWS_REGION_US_EAST_1) + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + iam_role = iam_client.create_role( + RoleName="test-role", + AssumeRolePolicyDocument="{}", + )["Role"]["Arn"] + for name in ("function-1", "function-2"): + lambda_client.create_function( + FunctionName=name, + Runtime="python3.7", + Role=iam_role, + Handler="lambda_function.lambda_handler", + Code={"ZipFile": create_zip_file().read()}, + PackageType="ZIP", + ) + awslambda = Lambda( + set_mocked_aws_provider( + audited_regions=[AWS_REGION_US_EAST_1], + audit_config={"max_lambda_functions": 1}, + ) + ) + + assert len(awslambda.functions) == 1 + + @mock_aws + def test_get_function_code_fetches_only_selected_functions(self): + lambda_client = client("lambda", region_name=AWS_REGION_US_EAST_1) + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + iam_role = iam_client.create_role( + RoleName="test-role", + AssumeRolePolicyDocument="{}", + )["Role"]["Arn"] + for name in ("function-1", "function-2"): + lambda_client.create_function( + FunctionName=name, + Runtime="python3.7", + Role=iam_role, + Handler="lambda_function.lambda_handler", + Code={"ZipFile": create_zip_file().read()}, + PackageType="ZIP", + ) + awslambda = Lambda( + set_mocked_aws_provider( + audited_regions=[AWS_REGION_US_EAST_1], + audit_config={"max_lambda_functions": 1}, + ) + ) + fetched = [] + + def fetch_function_code(function_name, _function_region): + fetched.append(function_name) + return mock.MagicMock() + + awslambda._fetch_function_code = fetch_function_code + + assert len(list(awslambda._get_function_code())) == 1 + assert len(fetched) == 1 diff --git a/tests/providers/aws/services/backup/backup_service_test.py b/tests/providers/aws/services/backup/backup_service_test.py index d4a7be392f..5894a62bda 100644 --- a/tests/providers/aws/services/backup/backup_service_test.py +++ b/tests/providers/aws/services/backup/backup_service_test.py @@ -1,11 +1,16 @@ from datetime import datetime +from types import SimpleNamespace from unittest.mock import patch import botocore from boto3 import client from moto import mock_aws -from prowler.providers.aws.services.backup.backup_service import Backup +from prowler.providers.aws.services.backup.backup_service import ( + Backup, + BackupVault, + RecoveryPoint, +) from tests.providers.aws.utils import ( AWS_ACCOUNT_NUMBER, AWS_REGION_EU_WEST_1, @@ -292,3 +297,248 @@ class TestBackupService: assert backup.recovery_points[0].backup_vault_region == "eu-west-1" assert backup.recovery_points[0].tags == [] assert backup.recovery_points[0].encrypted is True + + def test_recovery_point_limit_bounds_tag_calls_to_selected_points(self): + class FakePaginator: + def paginate(self, **kwargs): + return [ + { + "RecoveryPoints": [ + { + "RecoveryPointArn": "arn:aws:backup:eu-west-1:123456789012:recovery-point:new", + "IsEncrypted": True, + "CreationDate": datetime(2024, 1, 2), + }, + { + "RecoveryPointArn": "arn:aws:backup:eu-west-1:123456789012:recovery-point:old", + "IsEncrypted": True, + "CreationDate": datetime(2024, 1, 1), + }, + ] + } + ] + + class FakeBackupClient: + def __init__(self): + self.tag_calls = [] + + def get_paginator(self, name): + assert name == "list_recovery_points_by_backup_vault" + return FakePaginator() + + def list_tags(self, **kwargs): + self.tag_calls.append(kwargs["ResourceArn"]) + return {"Tags": {}} + + regional_client = FakeBackupClient() + backup = Backup.__new__(Backup) + backup.backup_vaults = [ + BackupVault( + arn="arn:aws:backup:eu-west-1:123456789012:backup-vault:vault", + name="vault", + region=AWS_REGION_EU_WEST_1, + encryption="", + recovery_points=2, + locked=False, + ) + ] + backup.recovery_points = [] + backup.recovery_point_limit = 1 + backup.regional_clients = {AWS_REGION_EU_WEST_1: regional_client} + + backup._list_recovery_points() + backup._select_recovery_points_for_analysis() + for recovery_point in backup.recovery_points: + backup._list_tags(recovery_point) + + assert [rp.id for rp in backup.recovery_points] == ["new"] + assert regional_client.tag_calls == [ + "arn:aws:backup:eu-west-1:123456789012:recovery-point:new" + ] + + def test_recovery_point_limit_selects_global_newest_across_vaults(self): + class FakePaginator: + def __init__(self, recovery_points_by_vault): + self.recovery_points_by_vault = recovery_points_by_vault + + def paginate(self, **kwargs): + assert "PageSize" not in kwargs + return [ + { + "RecoveryPoints": self.recovery_points_by_vault[ + kwargs["BackupVaultName"] + ] + } + ] + + class FakeBackupClient: + def __init__(self, recovery_points_by_vault): + self.recovery_points_by_vault = recovery_points_by_vault + + def get_paginator(self, name): + assert name == "list_recovery_points_by_backup_vault" + return FakePaginator(self.recovery_points_by_vault) + + backup = Backup.__new__(Backup) + backup.recovery_point_limit = 1 + backup.recovery_points = [] + backup.backup_vaults = [ + BackupVault( + arn="arn:aws:backup:eu-west-1:123456789012:backup-vault:old-vault", + name="old-vault", + region=AWS_REGION_EU_WEST_1, + encryption="", + recovery_points=1, + locked=False, + ), + BackupVault( + arn="arn:aws:backup:eu-west-1:123456789012:backup-vault:new-vault", + name="new-vault", + region=AWS_REGION_EU_WEST_1, + encryption="", + recovery_points=1, + locked=False, + ), + ] + backup.regional_clients = { + AWS_REGION_EU_WEST_1: FakeBackupClient( + { + "old-vault": [ + { + "RecoveryPointArn": "arn:aws:backup:eu-west-1:123456789012:recovery-point:old", + "IsEncrypted": True, + "CreationDate": datetime(2024, 1, 1), + } + ], + "new-vault": [ + { + "RecoveryPointArn": "arn:aws:backup:eu-west-1:123456789012:recovery-point:new", + "IsEncrypted": True, + "CreationDate": datetime(2024, 1, 2), + } + ], + } + ) + } + + backup._list_recovery_points() + backup._select_recovery_points_for_analysis() + + assert [rp.id for rp in backup.recovery_points] == ["new"] + + def test_recovery_point_limit_exposes_only_selected_resources(self): + backup = Backup.__new__(Backup) + backup.recovery_point_limit = 2 + backup.recovery_points = [] + backup.backup_vaults = [ + BackupVault( + arn="arn", + name="vault", + region="eu-west-1", + encryption="", + recovery_points=3, + locked=False, + ) + ] + + class Paginator: + def paginate(self, **_kwargs): + return [ + { + "RecoveryPoints": [ + { + "RecoveryPointArn": f"arn:aws:backup:eu-west-1:123456789012:recovery-point:{i}", + "IsEncrypted": True, + } + for i in range(3) + ] + } + ] + + backup.regional_clients = { + "eu-west-1": SimpleNamespace(get_paginator=lambda _: Paginator()) + } + tagged = [] + + def list_tags(recovery_point): + tagged.append(recovery_point.arn) + + backup._list_tags = list_tags + + backup._list_recovery_points() + backup._select_recovery_points_for_analysis() + for recovery_point in backup.recovery_points: + backup._list_tags(recovery_point) + + assert len(backup.recovery_points) == 2 + assert len(tagged) == 2 + + def test_recovery_point_limit_uses_deterministic_tie_breaker(self): + backup = Backup.__new__(Backup) + backup.recovery_point_limit = 2 + backup.recovery_points = [ + RecoveryPoint( + arn="arn:aws:backup:us-east-1:123456789012:recovery-point:z", + id="z", + region="us-east-1", + backup_vault_name="vault-b", + encrypted=True, + backup_vault_region="us-east-1", + ), + RecoveryPoint( + arn="arn:aws:backup:eu-west-1:123456789012:recovery-point:b", + id="b", + region="eu-west-1", + backup_vault_name="vault-b", + encrypted=True, + backup_vault_region="eu-west-1", + ), + RecoveryPoint( + arn="arn:aws:backup:eu-west-1:123456789012:recovery-point:a", + id="a", + region="eu-west-1", + backup_vault_name="vault-a", + encrypted=True, + backup_vault_region="eu-west-1", + ), + ] + + backup._select_recovery_points_for_analysis() + + assert [rp.id for rp in backup.recovery_points] == ["a", "b"] + + def test_recovery_point_limit_keeps_newest_before_tie_breaker(self): + backup = Backup.__new__(Backup) + backup.recovery_point_limit = 2 + backup.recovery_points = [ + RecoveryPoint( + arn="arn:aws:backup:eu-west-1:123456789012:recovery-point:older-a", + id="older-a", + region="eu-west-1", + backup_vault_name="vault-a", + encrypted=True, + backup_vault_region="eu-west-1", + creation_date=datetime(2024, 1, 1), + ), + RecoveryPoint( + arn="arn:aws:backup:us-east-1:123456789012:recovery-point:newer-z", + id="newer-z", + region="us-east-1", + backup_vault_name="vault-z", + encrypted=True, + backup_vault_region="us-east-1", + creation_date=datetime(2024, 1, 2), + ), + RecoveryPoint( + arn="arn:aws:backup:eu-west-1:123456789012:recovery-point:missing-date", + id="missing-date", + region="eu-west-1", + backup_vault_name="vault-a", + encrypted=True, + backup_vault_region="eu-west-1", + ), + ] + + backup._select_recovery_points_for_analysis() + + assert [rp.id for rp in backup.recovery_points] == ["newer-z", "older-a"] diff --git a/tests/providers/aws/services/bedrock/bedrock_model_invocation_logs_encryption_enabled/bedrock_model_invocation_logs_encryption_enabled_test.py b/tests/providers/aws/services/bedrock/bedrock_model_invocation_logs_encryption_enabled/bedrock_model_invocation_logs_encryption_enabled_test.py index 4630ad25a0..8c45a9d56b 100644 --- a/tests/providers/aws/services/bedrock/bedrock_model_invocation_logs_encryption_enabled/bedrock_model_invocation_logs_encryption_enabled_test.py +++ b/tests/providers/aws/services/bedrock/bedrock_model_invocation_logs_encryption_enabled/bedrock_model_invocation_logs_encryption_enabled_test.py @@ -227,6 +227,72 @@ class Test_bedrock_model_invocation_logs_encryption_enabled: assert result[0].region == AWS_REGION_US_EAST_1 assert result[0].resource_tags == [] + def test_cloudwatch_logging_uses_complete_log_group_index(self): + from prowler.providers.aws.services.bedrock.bedrock_service import ( + LoggingConfiguration, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + LogGroup, + ) + + bedrock_client = mock.MagicMock() + bedrock_client.logging_configurations = { + AWS_REGION_US_EAST_1: LoggingConfiguration( + enabled=True, + cloudwatch_log_group="Test", + ) + } + bedrock_client._get_model_invocation_logging_arn_template.return_value = ( + "arn:aws:bedrock:us-east-1:123456789012:model-invocation-logging" + ) + + logs_client = mock.MagicMock() + logs_client.audited_partition = "aws" + logs_client.audited_account = "123456789012" + logs_client.log_groups = {} + logs_client.all_log_groups = { + "arn:aws:logs:us-east-1:123456789012:log-group:Test:*": LogGroup( + arn="arn:aws:logs:us-east-1:123456789012:log-group:Test:*", + name="Test", + retention_days=30, + never_expire=False, + kms_id=None, + region=AWS_REGION_US_EAST_1, + ) + } + + s3_client = mock.MagicMock() + s3_client.audited_partition = "aws" + s3_client.buckets = {} + + with ( + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_model_invocation_logs_encryption_enabled.bedrock_model_invocation_logs_encryption_enabled.bedrock_client", + new=bedrock_client, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_model_invocation_logs_encryption_enabled.bedrock_model_invocation_logs_encryption_enabled.logs_client", + new=logs_client, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_model_invocation_logs_encryption_enabled.bedrock_model_invocation_logs_encryption_enabled.s3_client", + new=s3_client, + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_model_invocation_logs_encryption_enabled.bedrock_model_invocation_logs_encryption_enabled import ( + bedrock_model_invocation_logs_encryption_enabled, + ) + + check = bedrock_model_invocation_logs_encryption_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Bedrock Model Invocation logs are not encrypted in CloudWatch Log Group: Test." + ) + @mock_aws def test_s3_and_cloudwatch_logging_encrypted(self): logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) diff --git a/tests/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets_test.py b/tests/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets_test.py index 6b8d0d9b15..ad9d277788 100644 --- a/tests/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets_test.py +++ b/tests/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets_test.py @@ -38,7 +38,10 @@ class Test_cloudformation_stack_outputs_find_secrets: Stack( arn="arn:aws:cloudformation:eu-west-1:123456789012:stack/Test-Stack/796c8d26-b390-41d7-a23c-0702c4e78b60", name=stack_name, - outputs=["DB_PASSWORD:foobar123", "ENV:DEV"], + outputs=[ + "DB_KEY:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", + "ENV:DEV", + ], region=AWS_REGION, ) ] @@ -66,7 +69,7 @@ class Test_cloudformation_stack_outputs_find_secrets: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in CloudFormation Stack {stack_name} Outputs -> Secret Keyword in Output 1." + == f"Potential secret found in CloudFormation Stack {stack_name} Outputs -> JSON Web Token (base64url-encoded) in Output 1." ) assert result[0].resource_id == "Test-Stack" assert ( diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_group_no_secrets_in_logs/cloudwatch_log_group_no_secrets_in_logs_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_group_no_secrets_in_logs/cloudwatch_log_group_no_secrets_in_logs_test.py index e9eb00175b..afa082b35d 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_group_no_secrets_in_logs/cloudwatch_log_group_no_secrets_in_logs_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_group_no_secrets_in_logs/cloudwatch_log_group_no_secrets_in_logs_test.py @@ -132,7 +132,7 @@ class Test_cloudwatch_log_group_no_secrets_in_logs: logEvents=[ { "timestamp": timestamp, - "message": "password = password123", + "message": 'password = "Tr0ub4dor3xKq9vLmZ"', } ], ) @@ -174,7 +174,7 @@ class Test_cloudwatch_log_group_no_secrets_in_logs: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secrets found in log group test in log stream test stream at {dttimestamp} - Secret Keyword on line 1." + == f"Potential secrets found in log group test in log stream test stream at {dttimestamp} - Generic Password on line 1." ) assert result[0].resource_id == "test" assert ( @@ -222,3 +222,323 @@ class Test_cloudwatch_log_group_no_secrets_in_logs: result = check.execute() assert len(result) == 0 + + @mock_aws + def test_cloudwatch_multiline_event_all_secrets_ignored_is_pass(self): + # Regression: a multiline event whose secrets are all dropped by the + # rescan (e.g. filtered by secrets_ignore_patterns) must NOT produce a + # FAIL with no actual secret evidence. + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + logs_client.create_log_group(logGroupName="test", tags={"test": "test"}) + logs_client.create_log_stream(logGroupName="test", logStreamName="test stream") + logs_client.put_log_events( + logGroupName="test", + logStreamName="test stream", + logEvents=[ + { + "timestamp": timestamp, + # Valid JSON so the rescan expands it to multiple lines. + "message": '{"api_key": "AKIAIOSFODNN7EXAMPLE", "note": "x"}', + } + ], + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import Logs + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_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_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.detect_secrets_scan_batch", + side_effect=[ + # Phase 1: stream flagged on its single (multiline) event. + { + ("test", "test stream"): [ + { + "type": "AWS Access Key", + "line_number": 1, + "filename": "data", + "hashed_secret": "x", + "is_verified": False, + } + ] + }, + # Phase 3: rescan drops everything (all secrets ignored). + {}, + ], + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs import ( + cloudwatch_log_group_no_secrets_in_logs, + ) + + check = cloudwatch_log_group_no_secrets_in_logs() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == "No secrets found in test log group." + + @mock_aws + def test_cloudwatch_scan_failure_reports_manual(self): + # A scanner failure on the stream scan must surface as MANUAL, not PASS. + from prowler.lib.utils.utils import SecretsScanError + + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + logs_client.create_log_group(logGroupName="test", tags={"test": "test"}) + logs_client.create_log_stream(logGroupName="test", logStreamName="test stream") + logs_client.put_log_events( + logGroupName="test", + logStreamName="test stream", + logEvents=[{"timestamp": timestamp, "message": "some log line"}], + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import Logs + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_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_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.detect_secrets_scan_batch", + side_effect=SecretsScanError("Kingfisher exited with code 1"), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs import ( + cloudwatch_log_group_no_secrets_in_logs, + ) + + check = cloudwatch_log_group_no_secrets_in_logs() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not scan" in result[0].status_extended + + @mock_aws + def test_two_multiline_events_same_timestamp_do_not_collide(self): + # Regression: a CloudWatch stream can hold several events sharing one + # millisecond timestamp. The multiline rescan must be keyed per event + # (not only per timestamp), otherwise the later event's payload + # overwrites the earlier one and secret evidence is lost. + log_group_arn = ( + f"arn:aws:logs:{AWS_REGION_US_EAST_1}:123456789012:log-group:test:*" + ) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + logs_client.create_log_group(logGroupName="test", tags={"test": "test"}) + logs_client.create_log_stream(logGroupName="test", logStreamName="test stream") + # Two distinct multiline (valid JSON) events at the same timestamp. + logs_client.put_log_events( + logGroupName="test", + logStreamName="test stream", + logEvents=[ + { + "timestamp": timestamp, + "message": '{"api_key": "AKIAIOSFODNN7EXAMPLE", "note": "a"}', + }, + { + "timestamp": timestamp, + "message": '{"secret": "AKIAI44QH8DHBEXAMPLE", "note": "b"}', + }, + ], + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import Logs + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_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_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.detect_secrets_scan_batch", + side_effect=[ + # Phase 1: both events flagged (one secret on each line). + { + (log_group_arn, "test stream"): [ + { + "type": "AWS Access Key", + "line_number": 1, + "filename": "data", + "hashed_secret": "a", + "is_verified": False, + }, + { + "type": "AWS Access Key", + "line_number": 2, + "filename": "data", + "hashed_secret": "b", + "is_verified": False, + }, + ] + }, + # Phase 3: each event is rescanned under its own key. If the + # keys collided, only one of these would survive. + { + ( + log_group_arn, + "test stream", + dttimestamp, + 0, + ): [ + { + "type": "AWS Access Key", + "line_number": 2, + "filename": "data", + "hashed_secret": "a", + "is_verified": False, + } + ], + ( + log_group_arn, + "test stream", + dttimestamp, + 1, + ): [ + { + "type": "AWS Access Key", + "line_number": 2, + "filename": "data", + "hashed_secret": "b", + "is_verified": False, + } + ], + }, + ], + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs import ( + cloudwatch_log_group_no_secrets_in_logs, + ) + + check = cloudwatch_log_group_no_secrets_in_logs() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + # Both events' secrets must be reported, not just the last one. + assert ( + result[0].status_extended + == f"Potential secrets found in log group test in log stream test stream at {dttimestamp} - AWS Access Key on line 2." + ) + + @mock_aws + def test_same_group_and_stream_names_in_two_regions_do_not_collide(self): + # Regression: log group and stream names are not unique across regions, + # so the per-stream key must be region-aware (ARN-based). Otherwise the + # secret found in one region would be reused for the same-named group in + # another region, producing a false FAIL. + group_name = "shared-name" + stream_name = "shared stream" + + us_client = client("logs", region_name=AWS_REGION_US_EAST_1) + us_client.create_log_group(logGroupName=group_name) + us_client.create_log_stream(logGroupName=group_name, logStreamName=stream_name) + us_client.put_log_events( + logGroupName=group_name, + logStreamName=stream_name, + logEvents=[ + { + "timestamp": timestamp, + "message": 'password = "Tr0ub4dor3xKq9vLmZ"', + } + ], + ) + + eu_client = client("logs", region_name=AWS_REGION_EU_WEST_1) + eu_client.create_log_group(logGroupName=group_name) + eu_client.create_log_stream(logGroupName=group_name, logStreamName=stream_name) + eu_client.put_log_events( + logGroupName=group_name, + logStreamName=stream_name, + logEvents=[{"timestamp": timestamp, "message": "just a normal log line"}], + ) + + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import Logs + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_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_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.logs_client", + new=Logs(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs import ( + cloudwatch_log_group_no_secrets_in_logs, + ) + + check = cloudwatch_log_group_no_secrets_in_logs() + result = check.execute() + + assert len(result) == 2 + by_region = {report.region: report for report in result} + # Only the region with the real secret must FAIL. + assert by_region[AWS_REGION_US_EAST_1].status == "FAIL" + assert by_region[AWS_REGION_EU_WEST_1].status == "PASS" diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py index 33d4bde7d7..3d2d53ffa0 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py @@ -4,6 +4,7 @@ from moto import mock_aws from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( CloudWatch, + LogGroup, Logs, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( @@ -188,7 +189,9 @@ class Test_CloudWatch_Service: arn = f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" logs = Logs(aws_provider) assert len(logs.log_groups) == 1 + assert len(logs.all_log_groups) == 1 assert arn in logs.log_groups + assert arn in logs.all_log_groups assert logs.log_groups[arn].name == "/log-group/test" assert logs.log_groups[arn].retention_days == 400 assert logs.log_groups[arn].kms_id == "test_kms_id" @@ -212,7 +215,9 @@ class Test_CloudWatch_Service: arn = f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" logs = Logs(aws_provider) assert len(logs.log_groups) == 1 + assert len(logs.all_log_groups) == 1 assert arn in logs.log_groups + assert arn in logs.all_log_groups assert logs.log_groups[arn].name == "/log-group/test" assert logs.log_groups[arn].never_expire # Since it never expires we don't use the retention_days @@ -221,6 +226,190 @@ class Test_CloudWatch_Service: assert logs.log_groups[arn].region == AWS_REGION_US_EAST_1 assert logs.log_groups[arn].tags == [{}] + def test_log_group_limit_exposes_only_selected_resources(self): + class FakeLogsClient: + def __init__(self): + self.filter_calls = [] + + def filter_log_events(self, **kwargs): + self.filter_calls.append(kwargs["logGroupName"]) + return {"events": []} + + regional_client = FakeLogsClient() + logs = Logs.__new__(Logs) + logs.log_group_limit = 1 + logs._log_groups_hydrated = set() + logs.regional_clients = {AWS_REGION_US_EAST_1: regional_client} + logs.events_per_log_group_threshold = 1000 + logs.log_groups = { + f"arn:{i}": LogGroup( + arn=f"arn:{i}", + name=f"log-{i}", + retention_days=30, + never_expire=False, + kms_id=None, + creation_time=i, + region=AWS_REGION_US_EAST_1, + ) + for i in range(3) + } + tagged = [] + + def list_tags(log_group): + tagged.append(log_group.arn) + + logs._list_tags_for_resource = list_tags + + logs._select_log_groups_for_analysis() + for log_group in logs.log_groups.values(): + logs._list_tags_for_resource(log_group) + logs._get_log_events(log_group) + + assert list(logs.log_groups) == ["arn:2"] + assert tagged == ["arn:2"] + assert regional_client.filter_calls == ["log-2"] + + def test_log_group_limit_selects_global_newest_across_regions(self): + class FakePaginator: + def __init__(self, log_groups): + self.log_groups = log_groups + + def paginate(self, **kwargs): + assert "PageSize" not in kwargs + return [{"logGroups": self.log_groups}] + + class FakeLogsClient: + def __init__(self, region, log_groups): + self.region = region + self.log_groups = log_groups + + def get_paginator(self, name): + assert name == "describe_log_groups" + return FakePaginator(self.log_groups) + + logs = Logs.__new__(Logs) + logs.all_log_groups = {} + logs.log_groups = {} + logs.log_group_limit = 1 + logs.audit_resources = [] + + logs._describe_log_groups( + FakeLogsClient( + "eu-west-1", + [ + { + "arn": "arn:aws:logs:eu-west-1:123456789012:log-group:old:*", + "logGroupName": "old", + "creationTime": 1, + } + ], + ) + ) + logs._describe_log_groups( + FakeLogsClient( + AWS_REGION_US_EAST_1, + [ + { + "arn": "arn:aws:logs:us-east-1:123456789012:log-group:new:*", + "logGroupName": "new", + "creationTime": 2, + } + ], + ) + ) + logs._select_log_groups_for_analysis() + + assert [log_group.name for log_group in logs.log_groups.values()] == ["new"] + assert [log_group.name for log_group in logs.all_log_groups.values()] == [ + "old", + "new", + ] + + def test_metric_filters_use_complete_log_group_index(self): + class FakePaginator: + def paginate(self): + return [ + { + "metricFilters": [ + { + "filterName": "test-filter", + "filterPattern": "test-pattern", + "logGroupName": "old", + "metricTransformations": [ + {"metricName": "test-metric"} + ], + } + ] + } + ] + + class FakeLogsClient: + region = AWS_REGION_US_EAST_1 + + def get_paginator(self, name): + assert name == "describe_metric_filters" + return FakePaginator() + + logs = Logs.__new__(Logs) + old_log_group = LogGroup( + arn="arn:old", + name="old", + retention_days=30, + never_expire=False, + kms_id=None, + creation_time=1, + region=AWS_REGION_US_EAST_1, + ) + logs.audited_partition = "aws" + logs.audited_account = AWS_ACCOUNT_NUMBER + logs.audit_resources = [] + logs.metric_filters = [] + logs.log_groups = {} + logs.all_log_groups = {old_log_group.arn: old_log_group} + logs._log_groups_hydrated = set() + logs._list_tags_for_resource = lambda log_group: None + + logs._describe_metric_filters(FakeLogsClient()) + + assert len(logs.metric_filters) == 1 + assert logs.metric_filters[0].log_group == old_log_group + + def test_log_group_collection_recovers_all_log_groups_after_access_denied(self): + class FakePaginator: + def paginate(self): + return [ + { + "logGroups": [ + { + "arn": "arn:aws:logs:us-east-1:123456789012:log-group:success:*", + "logGroupName": "success", + "creationTime": 1, + } + ] + } + ] + + class FakeLogsClient: + region = AWS_REGION_US_EAST_1 + + def get_paginator(self, name): + assert name == "describe_log_groups" + return FakePaginator() + + logs = Logs.__new__(Logs) + logs.all_log_groups = None + logs.log_groups = None + logs.audit_resources = [] + + logs._describe_log_groups(FakeLogsClient()) + + assert list(logs.all_log_groups) == [ + "arn:aws:logs:us-east-1:123456789012:log-group:success:*" + ] + assert list(logs.log_groups) == [ + "arn:aws:logs:us-east-1:123456789012:log-group:success:*" + ] + class Test_build_metric_filter_pattern: @pytest.mark.parametrize("bad_operator", ["==", "~=", "<", "<>", ">=", ""]) diff --git a/tests/providers/aws/services/codeartifact/codeartifact_service_test.py b/tests/providers/aws/services/codeartifact/codeartifact_service_test.py index 99325dd2ea..50af85b767 100644 --- a/tests/providers/aws/services/codeartifact/codeartifact_service_test.py +++ b/tests/providers/aws/services/codeartifact/codeartifact_service_test.py @@ -1,3 +1,4 @@ +from types import SimpleNamespace from unittest.mock import patch import botocore @@ -6,6 +7,7 @@ from prowler.providers.aws.services.codeartifact.codeartifact_service import ( CodeArtifact, LatestPackageVersionStatus, OriginInformationValues, + Repository, RestrictionValues, ) from tests.providers.aws.utils import ( @@ -208,6 +210,104 @@ class Test_CodeArtifact_Service: == OriginInformationValues.INTERNAL ) + def test_package_limit_bounds_package_version_lookups_to_selected_packages(self): + class FakePaginator: + def paginate(self, **kwargs): + return [ + { + "packages": [ + { + "format": "pypi", + "package": "first-package", + "originConfiguration": { + "restrictions": { + "publish": "ALLOW", + "upstream": "ALLOW", + } + }, + }, + { + "format": "pypi", + "package": "second-package", + "originConfiguration": { + "restrictions": { + "publish": "ALLOW", + "upstream": "ALLOW", + } + }, + }, + ] + } + ] + + class FakeCodeArtifactClient: + def __init__(self): + self.version_calls = [] + + def get_paginator(self, name): + assert name == "list_packages" + return FakePaginator() + + def list_package_versions(self, **kwargs): + self.version_calls.append(kwargs["package"]) + return { + "versions": [ + { + "version": "1.0.0", + "status": "Published", + "origin": {"originType": "INTERNAL"}, + } + ] + } + + regional_client = FakeCodeArtifactClient() + codeartifact = CodeArtifact.__new__(CodeArtifact) + codeartifact.repositories = { + TEST_REPOSITORY_ARN: Repository( + name="test-repository", + arn=TEST_REPOSITORY_ARN, + domain_name="test-domain", + domain_owner=AWS_ACCOUNT_NUMBER, + region=AWS_REGION_EU_WEST_1, + ) + } + codeartifact._packages_listed = set() + codeartifact.package_limit = 1 + codeartifact.regional_clients = {AWS_REGION_EU_WEST_1: regional_client} + + pairs = list(codeartifact._load_packages_for_analysis()) + + assert [package.name for _, package in pairs] == ["first-package"] + assert regional_client.version_calls == ["first-package"] + + def test_package_limit_exposes_only_selected_packages(self): + codeartifact = CodeArtifact.__new__(CodeArtifact) + codeartifact.package_limit = 2 + codeartifact._packages_listed = set() + repository = Repository( + name="repository", + arn="repo", + domain_name="domain", + domain_owner=AWS_ACCOUNT_NUMBER, + region=AWS_REGION_EU_WEST_1, + ) + codeartifact.repositories = {repository.arn: repository} + enriched = [] + + def iter_repository_packages(repository, limit=None): + for index in range(3): + if limit is not None and index >= limit: + return + enriched.append(index) + yield SimpleNamespace(name=f"package-{index}") + + codeartifact._iter_repository_packages = iter_repository_packages + + packages = list(codeartifact._load_packages_for_analysis()) + + assert [package.name for _, package in packages] == ["package-0", "package-1"] + assert enriched == [0, 1] + def mock_make_api_call_no_namespace(self, operation_name, kwarg): """Mock for packages without a namespace to exercise the else branch""" diff --git a/tests/providers/aws/services/codebuild/codebuild_project_no_secrets_in_variables/codebuild_project_no_secrets_in_variables_test.py b/tests/providers/aws/services/codebuild/codebuild_project_no_secrets_in_variables/codebuild_project_no_secrets_in_variables_test.py index c08d1c8bb3..8060f7db59 100644 --- a/tests/providers/aws/services/codebuild/codebuild_project_no_secrets_in_variables/codebuild_project_no_secrets_in_variables_test.py +++ b/tests/providers/aws/services/codebuild/codebuild_project_no_secrets_in_variables/codebuild_project_no_secrets_in_variables_test.py @@ -1,6 +1,10 @@ from unittest import mock -from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER, AWS_REGION_US_EAST_1 +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) class Test_codebuild_project_no_secrets_in_variables: @@ -202,7 +206,11 @@ class Test_codebuild_project_no_secrets_in_variables: environment_variables=[ { "name": "AWS_ACCESS_KEY_ID", - "value": "AKIAIOSFODNN7EXAMPLE", + # Realistic fake secret that Kingfisher detects. The classic + # "AKIAIOSFODNN7EXAMPLE" placeholder is suppressed by + # Kingfisher and its AWS Access Key rule is not enabled, so a + # detectable provider secret is used instead. + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "type": "PLAINTEXT", } ], @@ -231,15 +239,100 @@ class Test_codebuild_project_no_secrets_in_variables: assert len(result) == 1 assert result[0].status == "FAIL" + # The JWT paired with a "KEY" variable name yields both a + # JWT and a Generic API Key finding; order is non-deterministic. + assert result[0].status_extended.startswith( + "CodeBuild project SensitiveProject has sensitive environment plaintext credentials in variables:" + ) assert ( - result[0].status_extended - == "CodeBuild project SensitiveProject has sensitive environment plaintext credentials in variables: AWS Access Key in variable AWS_ACCESS_KEY_ID." + "JSON Web Token (base64url-encoded) in variable AWS_ACCESS_KEY_ID" + in result[0].status_extended + ) + assert ( + "Generic API Key in variable AWS_ACCESS_KEY_ID" + in result[0].status_extended ) assert result[0].region == AWS_REGION_US_EAST_1 assert result[0].resource_id == "SensitiveProject" assert result[0].resource_arn == project_arn assert result[0].resource_tags == [] + def test_project_with_verified_secret(self): + from prowler.lib.check.models import Severity + + codebuild_client = mock.MagicMock() + + from prowler.providers.aws.services.codebuild.codebuild_service import Project + + project_arn = f"arn:aws:codebuild:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:project/SensitiveProject" + codebuild_client.projects = { + project_arn: Project( + name="SensitiveProject", + arn=project_arn, + region=AWS_REGION_US_EAST_1, + last_invoked_time=None, + buildspec=None, + environment_variables=[ + { + "name": "EXAMPLE_VAR", + "value": "ExampleValue", + "type": "PLAINTEXT", + } + ], + tags=[], + ) + } + + codebuild_client.audit_config = { + "excluded_sensitive_environment_variables": [], + "secrets_validate": True, + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider( + audit_config={"secrets_validate": True} + ), + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_service.Codebuild", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_no_secrets_in_variables.codebuild_project_no_secrets_in_variables.codebuild_client", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_no_secrets_in_variables.codebuild_project_no_secrets_in_variables.detect_secrets_scan_batch", + return_value={ + (0, 0): [ + { + "type": "JSON Web Token (base64url-encoded)", + "line_number": 1, + "filename": "data", + "hashed_secret": "x", + "is_verified": True, + } + ] + }, + ) as mock_scan, + ): + from prowler.providers.aws.services.codebuild.codebuild_project_no_secrets_in_variables.codebuild_project_no_secrets_in_variables import ( + codebuild_project_no_secrets_in_variables, + ) + + check = codebuild_project_no_secrets_in_variables() + result = check.execute() + + # The check must forward secrets_validate from the config to the scan. + assert mock_scan.call_args.kwargs.get("validate") is True + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "confirmed to be live" in result[0].status_extended + assert result[0].resource_id == "SensitiveProject" + def test_project_with_sensitive_plaintext_credentials_exluded(self): codebuild_client = mock.MagicMock @@ -373,12 +466,12 @@ class Test_codebuild_project_no_secrets_in_variables: environment_variables=[ { "name": "AWS_DUMB_ACCESS_KEY", - "value": "AKIAIOSFODNN7EXAMPLE", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "type": "PLAINTEXT", }, { "name": "AWS_ACCESS_KEY_ID", - "value": "AKIAIOSFODNN7EXAMPLE", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "type": "PLAINTEXT", }, ], @@ -409,10 +502,21 @@ class Test_codebuild_project_no_secrets_in_variables: assert len(result) == 1 assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == "CodeBuild project SensitiveProject has sensitive environment plaintext credentials in variables: AWS Access Key in variable AWS_ACCESS_KEY_ID." + # AWS_DUMB_ACCESS_KEY is excluded, so only AWS_ACCESS_KEY_ID is + # scanned; its JWT + "KEY" name yields both a JWT and a + # Generic API Key finding with non-deterministic order. + assert result[0].status_extended.startswith( + "CodeBuild project SensitiveProject has sensitive environment plaintext credentials in variables:" ) + assert ( + "JSON Web Token (base64url-encoded) in variable AWS_ACCESS_KEY_ID" + in result[0].status_extended + ) + assert ( + "Generic API Key in variable AWS_ACCESS_KEY_ID" + in result[0].status_extended + ) + assert "AWS_DUMB_ACCESS_KEY" not in result[0].status_extended assert result[0].region == AWS_REGION_US_EAST_1 assert result[0].resource_id == "SensitiveProject" assert result[0].resource_arn == project_arn @@ -434,12 +538,12 @@ class Test_codebuild_project_no_secrets_in_variables: environment_variables=[ { "name": "AWS_DUMB_ACCESS_KEY", - "value": "AKIAIOSFODNN7EXAMPLE", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "type": "PLAINTEXT", }, { "name": "AWS_ACCESS_KEY_ID", - "value": "AKIAIOSFODNN7EXAMPLE", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "type": "PLAINTEXT", }, ], @@ -468,11 +572,80 @@ class Test_codebuild_project_no_secrets_in_variables: assert len(result) == 1 assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == "CodeBuild project SensitiveProject has sensitive environment plaintext credentials in variables: AWS Access Key in variable AWS_DUMB_ACCESS_KEY, AWS Access Key in variable AWS_ACCESS_KEY_ID." + # Both variables hold a JWT and have "KEY" in their name, so + # each yields a JWT and a Generic API Key finding; order is + # non-deterministic. + assert result[0].status_extended.startswith( + "CodeBuild project SensitiveProject has sensitive environment plaintext credentials in variables:" ) + for var_name in ("AWS_DUMB_ACCESS_KEY", "AWS_ACCESS_KEY_ID"): + assert ( + f"JSON Web Token (base64url-encoded) in variable {var_name}" + in result[0].status_extended + ) + assert ( + f"Generic API Key in variable {var_name}" + in result[0].status_extended + ) assert result[0].region == AWS_REGION_US_EAST_1 assert result[0].resource_id == "SensitiveProject" assert result[0].resource_arn == project_arn assert result[0].resource_tags == [] + + def test_scan_failure_reports_manual(self): + from prowler.lib.utils.utils import SecretsScanError + + codebuild_client = mock.MagicMock() + from prowler.providers.aws.services.codebuild.codebuild_service import Project + + project_arn = f"arn:aws:codebuild:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:project/SensitiveProject" + codebuild_client.projects = { + project_arn: Project( + name="SensitiveProject", + arn=project_arn, + region=AWS_REGION_US_EAST_1, + last_invoked_time=None, + buildspec=None, + environment_variables=[ + { + "name": "EXAMPLE_VAR", + "value": "ExampleValue", + "type": "PLAINTEXT", + } + ], + tags=[], + ) + } + codebuild_client.audit_config = { + "excluded_sensitive_environment_variables": [], + "secrets_ignore_patterns": [], + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_service.Codebuild", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_no_secrets_in_variables.codebuild_project_no_secrets_in_variables.codebuild_client", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_no_secrets_in_variables.codebuild_project_no_secrets_in_variables.detect_secrets_scan_batch", + side_effect=SecretsScanError("Kingfisher exited with code 1"), + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_no_secrets_in_variables.codebuild_project_no_secrets_in_variables import ( + codebuild_project_no_secrets_in_variables, + ) + + check = codebuild_project_no_secrets_in_variables() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not scan" in result[0].status_extended diff --git a/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data_test.py b/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data_test.py index 0e8d303fe0..a2f2dc705a 100644 --- a/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data_test.py +++ b/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data_test.py @@ -100,7 +100,7 @@ class Test_ec2_instance_secrets_user_data: ImageId=EXAMPLE_AMI_ID, MinCount=1, MaxCount=1, - UserData="DB_PASSWORD=foobar123", + UserData='DB_PASSWORD="Tr0ub4dor3xKq9vLmZ"', )[0] from prowler.providers.aws.services.ec2.ec2_service import EC2 @@ -130,7 +130,7 @@ class Test_ec2_instance_secrets_user_data: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in EC2 instance {instance.id} User Data -> Secret Keyword on line 1." + == f"Potential secret found in EC2 instance {instance.id} User Data -> Generic Password on line 1." ) assert result[0].resource_id == instance.id assert ( @@ -233,7 +233,7 @@ class Test_ec2_instance_secrets_user_data: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in EC2 instance {instance.id} User Data -> Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4." + == f"Potential secret found in EC2 instance {instance.id} User Data -> Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4." ) assert result[0].resource_id == instance.id assert ( @@ -327,7 +327,7 @@ class Test_ec2_instance_secrets_user_data: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in EC2 instance {instance.id} User Data -> Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4." + == f"Potential secret found in EC2 instance {instance.id} User Data -> Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4." ) assert result[0].resource_id == instance.id assert ( @@ -337,6 +337,64 @@ class Test_ec2_instance_secrets_user_data: assert result[0].resource_tags is None assert result[0].region == AWS_REGION_US_EAST_1 + @mock_aws + def test_one_ec2_with_verified_secret(self): + from prowler.lib.check.models import Severity + + ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1) + instance = ec2.create_instances( + ImageId=EXAMPLE_AMI_ID, + MinCount=1, + MaxCount=1, + UserData='STRIPE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"', + )[0] + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + audit_config={"secrets_validate": True}, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_instance_secrets_user_data.ec2_instance_secrets_user_data.ec2_client", + new=EC2(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_instance_secrets_user_data.ec2_instance_secrets_user_data.detect_secrets_scan_batch", + return_value={ + 0: [ + { + "type": "JSON Web Token (base64url-encoded)", + "line_number": 1, + "filename": "data", + "hashed_secret": "x", + "is_verified": True, + } + ] + }, + ) as mock_scan, + ): + from prowler.providers.aws.services.ec2.ec2_instance_secrets_user_data.ec2_instance_secrets_user_data import ( + ec2_instance_secrets_user_data, + ) + + check = ec2_instance_secrets_user_data() + result = check.execute() + + # The check must forward secrets_validate from the config to the scan. + assert mock_scan.call_args.kwargs.get("validate") is True + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "confirmed to be live" in result[0].status_extended + assert result[0].resource_id == instance.id + @mock_aws def test_one_secrets_with_unicode_error(self): invalid_utf8_bytes = b"\xc0\xaf" @@ -368,4 +426,50 @@ class Test_ec2_instance_secrets_user_data: check = ec2_instance_secrets_user_data() result = check.execute() - assert len(result) == 0 + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not decode User Data" in result[0].status_extended + + @mock_aws + def test_scan_failure_reports_manual(self): + from prowler.lib.utils.utils import SecretsScanError + + ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1) + instance = ec2.create_instances( + ImageId=EXAMPLE_AMI_ID, + MinCount=1, + MaxCount=1, + UserData='password = "Tr0ub4dor3xKq9vLmZ"', + )[0] + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + 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.ec2.ec2_instance_secrets_user_data.ec2_instance_secrets_user_data.ec2_client", + new=EC2(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_instance_secrets_user_data.ec2_instance_secrets_user_data.detect_secrets_scan_batch", + side_effect=SecretsScanError("Kingfisher exited with code 1"), + ), + ): + from prowler.providers.aws.services.ec2.ec2_instance_secrets_user_data.ec2_instance_secrets_user_data import ( + ec2_instance_secrets_user_data, + ) + + check = ec2_instance_secrets_user_data() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not scan" in result[0].status_extended + assert result[0].resource_id == instance.id diff --git a/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/fixtures/fixture b/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/fixtures/fixture index 2fb5138932..528ff40f8f 100644 --- a/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/fixtures/fixture +++ b/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/fixtures/fixture @@ -1,4 +1,4 @@ -DB_PASSWORD=foobar123 +DB_PASSWORD="Tr0ub4dor3xKq9vLmZ" DB_USER=foo -API_KEY=12345abcd -SERVICE_PASSWORD=bbaabb45 +STRIPE_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U +SERVICE_PASSWORD="Xy9zPq2wKmRtVbN4" diff --git a/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/fixtures/fixture.gz b/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/fixtures/fixture.gz index 6120fcfbc4..859b38cb62 100644 Binary files a/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/fixtures/fixture.gz and b/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/fixtures/fixture.gz differ diff --git a/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets_test.py b/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets_test.py index 0430ca5cae..dab6debb6b 100644 --- a/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets_test.py +++ b/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets_test.py @@ -29,7 +29,9 @@ def mock_make_api_call(self, operation_name, kwarg): "VersionNumber": 123, "LaunchTemplateData": { "UserData": b64encode( - "DB_PASSWORD=foobar123".encode(encoding_format_utf_8) + 'DB_PASSWORD="Tr0ub4dor3xKq9vLmZ"'.encode( + encoding_format_utf_8 + ) ).decode(encoding_format_utf_8), "NetworkInterfaces": [{"AssociatePublicIpAddress": True}], }, @@ -164,7 +166,7 @@ class Test_ec2_launch_template_no_secrets: assert result[0].status == "FAIL" assert ( result[0].status_extended - == "Potential secret found in User Data for EC2 Launch Template tester1 in template versions: Version 123: Secret Keyword on line 1." + == "Potential secret found in User Data for EC2 Launch Template tester1 in template versions: Version 123: Generic Password on line 1." ) assert result[0].resource_id == "lt-1234567890" assert result[0].region == AWS_REGION_US_EAST_1 @@ -212,7 +214,7 @@ class Test_ec2_launch_template_no_secrets: ) ec2_client.launch_templates = [launch_template] - ec2_client.audit_config = {"detect_secrets_plugins": None} + ec2_client.audit_config = {} with ( mock.patch( @@ -236,7 +238,7 @@ class Test_ec2_launch_template_no_secrets: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in User Data for EC2 Launch Template {launch_template_name} in template versions: Version 1: Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4, Version 2: Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4." + == f"Potential secret found in User Data for EC2 Launch Template {launch_template_name} in template versions: Version 1: Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4, Version 2: Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4." ) assert result[0].resource_id == launch_template_id assert result[0].region == AWS_REGION_US_EAST_1 @@ -290,7 +292,7 @@ class Test_ec2_launch_template_no_secrets: ) ec2_client.launch_templates = [launch_template] - ec2_client.audit_config = {"detect_secrets_plugins": None} + ec2_client.audit_config = {} with ( mock.patch( @@ -314,7 +316,7 @@ class Test_ec2_launch_template_no_secrets: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in User Data for EC2 Launch Template {launch_template_name} in template versions: Version 1: Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4." + == f"Potential secret found in User Data for EC2 Launch Template {launch_template_name} in template versions: Version 1: Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4." ) assert result[0].resource_id == launch_template_id assert result[0].region == AWS_REGION_US_EAST_1 @@ -358,7 +360,7 @@ class Test_ec2_launch_template_no_secrets: ) ec2_client.launch_templates = [launch_template] - ec2_client.audit_config = {"detect_secrets_plugins": None} + ec2_client.audit_config = {} with ( mock.patch( @@ -382,7 +384,7 @@ class Test_ec2_launch_template_no_secrets: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in User Data for EC2 Launch Template {launch_template_name} in template versions: Version 1: Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4." + == f"Potential secret found in User Data for EC2 Launch Template {launch_template_name} in template versions: Version 1: Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4." ) assert result[0].resource_id == launch_template_id assert result[0].region == AWS_REGION_US_EAST_1 @@ -391,6 +393,81 @@ class Test_ec2_launch_template_no_secrets: ) assert result[0].resource_tags == [] + def test_one_launch_template_with_verified_secret(self): + from prowler.lib.check.models import Severity + + ec2_client = mock.MagicMock() + launch_template_name = "tester" + launch_template_id = "lt-1234567890" + launch_template_arn = ( + f"arn:aws:ec2:us-east-1:123456789012:launch-template/{launch_template_id}" + ) + + launch_template_data = TemplateData( + user_data=b64encode( + "This is some user_data".encode(encoding_format_utf_8) + ).decode(encoding_format_utf_8), + associate_public_ip_address=True, + ) + + launch_template_versions = [ + LaunchTemplateVersion( + version_number=1, + template_data=launch_template_data, + ), + ] + + launch_template = LaunchTemplate( + name=launch_template_name, + id=launch_template_id, + arn=launch_template_arn, + region=AWS_REGION_US_EAST_1, + versions=launch_template_versions, + ) + + ec2_client.launch_templates = [launch_template] + ec2_client.audit_config = {"secrets_validate": True} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=ec2_client, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_launch_template_no_secrets.ec2_launch_template_no_secrets.ec2_client", + new=ec2_client, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_launch_template_no_secrets.ec2_launch_template_no_secrets.detect_secrets_scan_batch", + return_value={ + (0, 0): [ + { + "type": "JSON Web Token (base64url-encoded)", + "line_number": 1, + "filename": "data", + "hashed_secret": "x", + "is_verified": True, + } + ] + }, + ) as mock_scan, + ): + # Test Check + from prowler.providers.aws.services.ec2.ec2_launch_template_no_secrets.ec2_launch_template_no_secrets import ( + ec2_launch_template_no_secrets, + ) + + check = ec2_launch_template_no_secrets() + result = check.execute() + + # The check must forward secrets_validate from the config to the scan. + assert mock_scan.call_args.kwargs.get("validate") is True + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "confirmed to be live" in result[0].status_extended + assert result[0].resource_id == launch_template_id + @mock_aws def test_one_launch_template_without_user_data(self): launch_template_name = "tester" @@ -506,7 +583,7 @@ class Test_ec2_launch_template_no_secrets: launch_template_secrets, launch_template_no_secrets, ] - ec2_client.audit_config = {"detect_secrets_plugins": None} + ec2_client.audit_config = {} with ( mock.patch( @@ -530,7 +607,7 @@ class Test_ec2_launch_template_no_secrets: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in User Data for EC2 Launch Template {launch_template_name1} in template versions: Version 1: Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4." + == f"Potential secret found in User Data for EC2 Launch Template {launch_template_name1} in template versions: Version 1: Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4." ) assert result[0].resource_id == launch_template_id1 assert result[0].region == AWS_REGION_US_EAST_1 @@ -593,10 +670,10 @@ class Test_ec2_launch_template_no_secrets: result = check.execute() assert len(result) == 1 - assert result[0].status == "PASS" + assert result[0].status == "MANUAL" assert ( - result[0].status_extended - == f"No secrets found in User Data of any version for EC2 Launch Template {launch_template_name}." + f"Could not decode User Data for EC2 Launch Template {launch_template_name}" + in result[0].status_extended ) assert result[0].resource_id == launch_template_id assert result[0].region == AWS_REGION_US_EAST_1 diff --git a/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/fixtures/fixture b/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/fixtures/fixture index 2fb5138932..528ff40f8f 100644 --- a/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/fixtures/fixture +++ b/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/fixtures/fixture @@ -1,4 +1,4 @@ -DB_PASSWORD=foobar123 +DB_PASSWORD="Tr0ub4dor3xKq9vLmZ" DB_USER=foo -API_KEY=12345abcd -SERVICE_PASSWORD=bbaabb45 +STRIPE_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U +SERVICE_PASSWORD="Xy9zPq2wKmRtVbN4" diff --git a/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/fixtures/fixture.gz b/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/fixtures/fixture.gz index 6120fcfbc4..859b38cb62 100644 Binary files a/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/fixtures/fixture.gz and b/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/fixtures/fixture.gz differ diff --git a/tests/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used_test.py b/tests/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used_test.py index d1b77b9f8b..b959f4b257 100644 --- a/tests/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used_test.py +++ b/tests/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used_test.py @@ -14,6 +14,57 @@ EXAMPLE_AMI_ID = "ami-12c6146b" class Test_ec2_securitygroup_not_used: + def test_ec2_sg_used_by_lambda_outside_selected_analysis_limit(self): + from prowler.providers.aws.services.ec2.ec2_service import SecurityGroup + + sg_id = "sg-limited-out" + sg_name = "lambda-sg" + security_group = SecurityGroup( + name=sg_name, + region=AWS_REGION_US_EAST_1, + arn=f"arn:aws:ec2:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:security-group/{sg_id}", + id=sg_id, + vpc_id="vpc-test", + associated_sgs=[], + network_interfaces=[], + ingress_rules=[], + egress_rules=[], + tags=[], + ) + ec2_client = mock.MagicMock() + ec2_client.security_groups = {security_group.arn: security_group} + awslambda_client = mock.MagicMock() + awslambda_client.functions = {} + awslambda_client.security_groups_in_use = {sg_id} + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.ec2_client", + new=ec2_client, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.awslambda_client", + new=awslambda_client, + ), + ): + from prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used import ( + ec2_securitygroup_not_used, + ) + + result = ec2_securitygroup_not_used().execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Security group {sg_name} ({sg_id}) it is being used." + ) + @mock_aws def test_ec2_default_sgs(self): # Create EC2 Mocked Resources diff --git a/tests/providers/aws/services/ec2/ec2_service_test.py b/tests/providers/aws/services/ec2/ec2_service_test.py index aca200dd61..1302952e95 100644 --- a/tests/providers/aws/services/ec2/ec2_service_test.py +++ b/tests/providers/aws/services/ec2/ec2_service_test.py @@ -11,7 +11,7 @@ from freezegun import freeze_time from moto import mock_aws from prowler.config.config import encoding_format_utf_8 -from prowler.providers.aws.services.ec2.ec2_service import EC2 +from prowler.providers.aws.services.ec2.ec2_service import EC2, Snapshot from tests.providers.aws.utils import ( AWS_ACCOUNT_NUMBER, AWS_REGION_EU_WEST_1, @@ -103,6 +103,99 @@ class Test_EC2_Service: ec2 = EC2(aws_provider) assert ec2.audited_account == AWS_ACCOUNT_NUMBER + def test_snapshot_limit_bounds_public_attribute_calls_to_latest_selected(self): + class FakeEC2Client: + def __init__(self): + self.calls = [] + + def describe_snapshot_attribute(self, **kwargs): + self.calls.append(kwargs["SnapshotId"]) + return {"CreateVolumePermissions": []} + + regional_client = FakeEC2Client() + ec2 = EC2.__new__(EC2) + ec2.snapshots = [ + Snapshot( + id="snap-old", + arn="arn:aws:ec2:eu-west-1:123456789012:snapshot/snap-old", + region=AWS_REGION_EU_WEST_1, + encrypted=True, + start_time=datetime(2024, 1, 1), + volume="vol-old", + ), + Snapshot( + id="snap-new", + arn="arn:aws:ec2:eu-west-1:123456789012:snapshot/snap-new", + region=AWS_REGION_EU_WEST_1, + encrypted=True, + start_time=datetime(2024, 1, 2), + volume="vol-new", + ), + ] + ec2.snapshot_limit = 1 + ec2.regional_clients = {AWS_REGION_EU_WEST_1: regional_client} + + ec2._select_snapshots_for_analysis() + for snapshot in ec2.snapshots: + ec2._determine_public_snapshots(snapshot) + + assert [snapshot.id for snapshot in ec2.snapshots] == ["snap-new"] + assert regional_client.calls == ["snap-new"] + + def test_snapshot_limit_preserves_volume_index_and_selects_global_latest(self): + class FakePaginator: + def __init__(self, snapshots): + self.snapshots = snapshots + + def paginate(self, **kwargs): + assert "PageSize" not in kwargs + return [{"Snapshots": self.snapshots}] + + class FakeEC2Client: + def __init__(self, region, snapshots): + self.region = region + self.snapshots = snapshots + + def get_paginator(self, name): + assert name == "describe_snapshots" + return FakePaginator(self.snapshots) + + ec2 = EC2.__new__(EC2) + ec2.snapshots = [] + ec2.volumes_with_snapshots = {} + ec2.regions_with_snapshots = {} + ec2.snapshot_limit = 1 + ec2.audit_resources = [] + ec2.audited_partition = "aws" + ec2.audited_account = AWS_ACCOUNT_NUMBER + old_client = FakeEC2Client( + AWS_REGION_EU_WEST_1, + [ + { + "SnapshotId": "snap-old", + "VolumeId": "vol-old", + "StartTime": datetime(2024, 1, 1), + } + ], + ) + new_client = FakeEC2Client( + AWS_REGION_US_EAST_1, + [ + { + "SnapshotId": "snap-new", + "VolumeId": "vol-new", + "StartTime": datetime(2024, 1, 2), + } + ], + ) + + ec2._describe_snapshots(old_client) + ec2._describe_snapshots(new_client) + ec2._select_snapshots_for_analysis() + + assert ec2.volumes_with_snapshots == {"vol-old": True, "vol-new": True} + assert [snapshot.id for snapshot in ec2.snapshots] == ["snap-new"] + # Test EC2 Describe Instances @mock_aws @freeze_time(MOCK_DATETIME) @@ -346,6 +439,24 @@ class Test_EC2_Service: assert not snapshot.encrypted assert snapshot.public + @mock_aws + def test_snapshot_limit_exposes_only_selected_snapshots(self): + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + ec2_resource = resource("ec2", region_name=AWS_REGION_US_EAST_1) + volume_id = ec2_resource.create_volume( + AvailabilityZone="us-east-1a", + Size=80, + VolumeType="gp2", + ).id + for _ in range(3): + ec2_client.create_snapshot(VolumeId=volume_id) + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], audit_config={"max_ebs_snapshots": 1} + ) + ec2 = EC2(aws_provider) + + assert len(ec2.snapshots) == 1 + # Test EC2 Instance User Data @mock_aws def test_get_instance_user_data(self): diff --git a/tests/providers/aws/services/ecs/ecs_service_test.py b/tests/providers/aws/services/ecs/ecs_service_test.py index b472e94b88..0427113867 100644 --- a/tests/providers/aws/services/ecs/ecs_service_test.py +++ b/tests/providers/aws/services/ecs/ecs_service_test.py @@ -3,7 +3,11 @@ from unittest.mock import patch import botocore from prowler.providers.aws.services.ecs.ecs_service import ECS -from tests.providers.aws.utils import AWS_REGION_EU_WEST_1, set_mocked_aws_provider +from tests.providers.aws.utils import ( + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) make_api_call = botocore.client.BaseClient._make_api_call @@ -115,6 +119,23 @@ def mock_generate_regional_clients(provider, service): return {AWS_REGION_EU_WEST_1: regional_client} +def mock_generate_multi_region_clients(provider, service): + eu_west_1_client = provider._session.current_session.client( + service, region_name=AWS_REGION_EU_WEST_1 + ) + eu_west_1_client.region = AWS_REGION_EU_WEST_1 + + us_east_1_client = provider._session.current_session.client( + service, region_name=AWS_REGION_US_EAST_1 + ) + us_east_1_client.region = AWS_REGION_US_EAST_1 + + return { + AWS_REGION_EU_WEST_1: eu_west_1_client, + AWS_REGION_US_EAST_1: us_east_1_client, + } + + @patch( "prowler.providers.aws.aws_provider.AwsProvider.generate_regional_clients", new=mock_generate_regional_clients, @@ -139,7 +160,6 @@ class Test_ECS_Service: ecs = ECS(aws_provider) assert ecs.session.__class__.__name__ == "Session" - # Test list ECS task definitions @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) def test_list_task_definitions(self): aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) @@ -201,6 +221,169 @@ class Test_ECS_Service: .readonly_rootfilesystem ) + def test_task_definitions_are_loaded_once_for_analysis(self): + describe_calls = [] + list_calls = [] + + def counting_make_api_call(self, operation_name, kwarg): + if operation_name == "ListTaskDefinitions": + list_calls.append(kwarg) + return { + "taskDefinitionArns": [ + f"arn:aws:ecs:eu-west-1:123456789012:task-definition/fam:{i}" + for i in (3, 2, 1) + ] + } + if operation_name == "DescribeTaskDefinition": + describe_calls.append(kwarg["taskDefinition"]) + return { + "taskDefinition": { + "containerDefinitions": [], + "networkMode": "bridge", + "pidMode": "", + "tags": [], + } + } + return make_api_call(self, operation_name, kwarg) + + with patch( + "botocore.client.BaseClient._make_api_call", new=counting_make_api_call + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + ecs = ECS(aws_provider) + + assert [td.revision for td in ecs.task_definitions.values()] == [ + "3", + "2", + "1", + ] + assert list_calls == [{"sort": "DESC"}] + assert len(describe_calls) == 3 + + def test_task_definition_limit_exposes_only_selected_resources(self): + describe_calls = [] + + def counting_make_api_call(self, operation_name, kwarg): + if operation_name == "ListTaskDefinitions": + return { + "taskDefinitionArns": [ + f"arn:aws:ecs:eu-west-1:123456789012:task-definition/fam:{i}" + for i in (3, 2, 1) + ] + } + if operation_name == "DescribeTaskDefinition": + describe_calls.append(kwarg["taskDefinition"]) + return { + "taskDefinition": { + "containerDefinitions": [], + "networkMode": "bridge", + "pidMode": "", + "tags": [], + } + } + return make_api_call(self, operation_name, kwarg) + + with patch( + "botocore.client.BaseClient._make_api_call", new=counting_make_api_call + ): + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1], audit_config={"max_ecs_task_definitions": 2} + ) + ecs = ECS(aws_provider) + + assert [td.revision for td in ecs.task_definitions.values()] == ["3", "2"] + assert len(describe_calls) == 2 + + def test_task_definition_limit_bounds_describe_calls(self): + describe_calls = [] + + def counting_make_api_call(self, operation_name, kwarg): + if operation_name == "ListTaskDefinitions": + return { + "taskDefinitionArns": [ + f"arn:aws:ecs:eu-west-1:123456789012:task-definition/fam:{i}" + for i in (3, 2, 1) + ] + } + if operation_name == "DescribeTaskDefinition": + describe_calls.append(kwarg["taskDefinition"]) + return { + "taskDefinition": { + "containerDefinitions": [], + "networkMode": "bridge", + "pidMode": "", + "tags": [], + } + } + return mock_make_api_call(self, operation_name, kwarg) + + with patch( + "botocore.client.BaseClient._make_api_call", new=counting_make_api_call + ): + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1], audit_config={"max_ecs_task_definitions": 1} + ) + ecs = ECS(aws_provider) + + assert [td.revision for td in ecs.task_definitions.values()] == ["3"] + assert describe_calls == [ + "arn:aws:ecs:eu-west-1:123456789012:task-definition/fam:3" + ] + + def test_task_definition_limit_does_not_starve_later_regions(self): + describe_calls = [] + + def counting_make_api_call(self, operation_name, kwarg): + region = self.meta.region_name + if operation_name == "ListTaskDefinitions": + task_definition_revisions = { + AWS_REGION_EU_WEST_1: (3, 2, 1), + AWS_REGION_US_EAST_1: (9,), + }[region] + return { + "taskDefinitionArns": [ + f"arn:aws:ecs:{region}:123456789012:task-definition/fam:{revision}" + for revision in task_definition_revisions + ] + } + if operation_name == "DescribeTaskDefinition": + describe_calls.append(kwarg["taskDefinition"]) + return { + "taskDefinition": { + "containerDefinitions": [], + "networkMode": "bridge", + "pidMode": "", + "tags": [], + } + } + if operation_name == "ListClusters": + return {"clusterArns": []} + return mock_make_api_call(self, operation_name, kwarg) + + with ( + patch( + "prowler.providers.aws.aws_provider.AwsProvider.generate_regional_clients", + new=mock_generate_multi_region_clients, + ), + patch( + "botocore.client.BaseClient._make_api_call", new=counting_make_api_call + ), + ): + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + audit_config={"max_ecs_task_definitions": 2}, + ) + ecs = ECS(aws_provider) + + assert [td.region for td in ecs.task_definitions.values()] == [ + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + ] + assert set(describe_calls) == { + "arn:aws:ecs:eu-west-1:123456789012:task-definition/fam:3", + "arn:aws:ecs:us-east-1:123456789012:task-definition/fam:9", + } + # Test list ECS clusters @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) def test_list_clusters(self): diff --git a/tests/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets_test.py b/tests/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets_test.py index 24c27ccf0b..753c00b0ef 100644 --- a/tests/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets_test.py +++ b/tests/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets_test.py @@ -11,9 +11,17 @@ CONTAINER_NAME = "test-container" ENV_VAR_NAME_NO_SECRETS = "host" ENV_VAR_VALUE_NO_SECRETS = "localhost:1234" ENV_VAR_NAME_WITH_KEYWORD = "DB_PASSWORD" -ENV_VAR_VALUE_WITH_SECRETS = "srv://admin:pass@db" +# Realistic fake secrets that Kingfisher actually detects (placeholders such as +# the previous "srv://admin:pass@db" basic-auth URL are no longer flagged). +# A JWT fires on any line of the dumped JSON (even when followed by a +# trailing comma); a keyword-named variable additionally fires the generic +# keyword rule when it is the last entry in the dump. +ENV_VAR_VALUE_WITH_SECRETS = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" ENV_VAR_NAME_WITH_KEYWORD2 = "DATABASE_PASSWORD" -ENV_VAR_VALUE_WITH_SECRETS2 = "srv://admin:password@database" +ENV_VAR_VALUE_WITH_SECRETS2 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5ODc2NTQzMjEwIiwibmFtZSI6IkphbmUifQ.s5LqY8mC2pX1vN0bQwReTyUiOpAsDfGhJkLzXcVbNm0" +# Generic password/secret assignment value (detected only on the last entry of +# the JSON dump, where there is no trailing comma after the value). +ENV_VAR_VALUE_GENERIC_SECRET = "Tr0ub4dor3xKq9vLmZ" class Test_ecs_task_definitions_no_environment_secrets: @@ -143,7 +151,7 @@ class Test_ecs_task_definitions_no_environment_secrets: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> Basic Auth Credentials on the environment variable host." + == f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> JSON Web Token (base64url-encoded) on the environment variable host." ) assert result[0].resource_id == f"{TASK_NAME}:{TASK_REVISION}" assert result[0].resource_arn == task_arn @@ -167,7 +175,7 @@ class Test_ecs_task_definitions_no_environment_secrets: "environment": [ { "name": ENV_VAR_NAME_WITH_KEYWORD, - "value": ENV_VAR_VALUE_NO_SECRETS, + "value": ENV_VAR_VALUE_GENERIC_SECRET, } ], } @@ -198,7 +206,7 @@ class Test_ecs_task_definitions_no_environment_secrets: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> Secret Keyword on the environment variable DB_PASSWORD." + == f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> Generic Password on the environment variable DB_PASSWORD." ) assert result[0].resource_id == f"{TASK_NAME}:{TASK_REVISION}" assert result[0].resource_arn == task_arn @@ -251,9 +259,20 @@ class Test_ecs_task_definitions_no_environment_secrets: result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" + # The keyword-named variable holding a real secret triggers both the + # generic keyword rule and the JWT rule on the same line. + # Kingfisher emits same-line findings in a non-deterministic order, so + # assert both are present without pinning their order. + assert result[0].status_extended.startswith( + f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> " + ) assert ( - result[0].status_extended - == f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> Secret Keyword on the environment variable DB_PASSWORD, Basic Auth Credentials on the environment variable DB_PASSWORD." + "JSON Web Token (base64url-encoded) on the environment variable DB_PASSWORD" + in result[0].status_extended + ) + assert ( + "Generic Password on the environment variable DB_PASSWORD" + in result[0].status_extended ) assert result[0].resource_id == f"{TASK_NAME}:{TASK_REVISION}" assert result[0].resource_arn == task_arn @@ -310,9 +329,23 @@ class Test_ecs_task_definitions_no_environment_secrets: result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" + # DB_PASSWORD holds a JWT under a keyword name, so it fires + # both the JWT rule and the generic keyword rule on the + # same line (non-deterministic order); host holds a second JWT. + assert result[0].status_extended.startswith( + f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> " + ) assert ( - result[0].status_extended - == f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> Secret Keyword on the environment variable DB_PASSWORD, Basic Auth Credentials on the environment variable DB_PASSWORD, Basic Auth Credentials on the environment variable host." + "JSON Web Token (base64url-encoded) on the environment variable DB_PASSWORD" + in result[0].status_extended + ) + assert ( + "Generic Password on the environment variable DB_PASSWORD" + in result[0].status_extended + ) + assert ( + "JSON Web Token (base64url-encoded) on the environment variable host" + in result[0].status_extended ) assert result[0].resource_id == f"{TASK_NAME}:{TASK_REVISION}" assert result[0].resource_arn == task_arn @@ -340,7 +373,7 @@ class Test_ecs_task_definitions_no_environment_secrets: }, { "name": ENV_VAR_NAME_WITH_KEYWORD2, - "value": ENV_VAR_VALUE_WITH_SECRETS2, + "value": ENV_VAR_VALUE_GENERIC_SECRET, }, ], } @@ -369,9 +402,24 @@ class Test_ecs_task_definitions_no_environment_secrets: result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" + # DB_PASSWORD holds a JWT under a keyword name, so it fires + # both the JWT and the generic keyword rule on the same line + # (non-deterministic order); DATABASE_PASSWORD fires the generic + # keyword rule on its own line. + assert result[0].status_extended.startswith( + f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> " + ) assert ( - result[0].status_extended - == f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> Secret Keyword on the environment variable DB_PASSWORD, Basic Auth Credentials on the environment variable DB_PASSWORD, Basic Auth Credentials on the environment variable DATABASE_PASSWORD, Secret Keyword on the environment variable DATABASE_PASSWORD." + "JSON Web Token (base64url-encoded) on the environment variable DB_PASSWORD" + in result[0].status_extended + ) + assert ( + "Generic Password on the environment variable DB_PASSWORD" + in result[0].status_extended + ) + assert ( + "Generic Password on the environment variable DATABASE_PASSWORD" + in result[0].status_extended ) assert result[0].resource_id == f"{TASK_NAME}:{TASK_REVISION}" assert result[0].resource_arn == task_arn diff --git a/tests/providers/aws/services/iam/iam_inline_policy_allows_privilege_escalation/iam_inline_policy_allows_privilege_escalation_test.py b/tests/providers/aws/services/iam/iam_inline_policy_allows_privilege_escalation/iam_inline_policy_allows_privilege_escalation_test.py index 176942ea0b..eda9588aef 100644 --- a/tests/providers/aws/services/iam/iam_inline_policy_allows_privilege_escalation/iam_inline_policy_allows_privilege_escalation_test.py +++ b/tests/providers/aws/services/iam/iam_inline_policy_allows_privilege_escalation/iam_inline_policy_allows_privilege_escalation_test.py @@ -1239,6 +1239,7 @@ class Test_iam_inline_policy_allows_privilege_escalation: "Action": [ "iam:PassRole", "bedrock-agentcore:CreateCodeInterpreter", + "bedrock-agentcore:StartCodeInterpreterSession", "bedrock-agentcore:InvokeCodeInterpreter", ], "Resource": "*", @@ -1286,6 +1287,10 @@ class Test_iam_inline_policy_allows_privilege_escalation: assert search( "bedrock-agentcore:CreateCodeInterpreter", result[0].status_extended ) + assert search( + "bedrock-agentcore:StartCodeInterpreterSession", + result[0].status_extended, + ) assert search( "bedrock-agentcore:InvokeCodeInterpreter", result[0].status_extended ) diff --git a/tests/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation_test.py b/tests/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation_test.py index 850a5ba2cb..35f0fd1f0e 100644 --- a/tests/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation_test.py +++ b/tests/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation_test.py @@ -928,6 +928,7 @@ class Test_iam_policy_allows_privilege_escalation: "Action": [ "iam:PassRole", "bedrock-agentcore:CreateCodeInterpreter", + "bedrock-agentcore:StartCodeInterpreterSession", "bedrock-agentcore:InvokeCodeInterpreter", ], "Resource": "*", @@ -973,10 +974,391 @@ class Test_iam_policy_allows_privilege_escalation: assert search( "bedrock-agentcore:CreateCodeInterpreter", result[0].status_extended ) + assert search( + "bedrock-agentcore:StartCodeInterpreterSession", + result[0].status_extended, + ) assert search( "bedrock-agentcore:InvokeCodeInterpreter", result[0].status_extended ) + @mock_aws + def test_iam_policy_allows_privilege_escalation_agentcore_invoke_runtime_command( + self, + ): + """Test detection of AgentCore Runtime/Harness privilege escalation via InvokeAgentRuntimeCommand on an existing resource.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + policy_name = "agentcore_invoke_runtime_command_policy" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:InvokeAgentRuntimeCommand", + ], + "Resource": "*", + } + ], + } + + policy_arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import ( + iam_policy_allows_privilege_escalation, + ) + + check = iam_policy_allows_privilege_escalation() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == policy_name + assert result[0].resource_arn == policy_arn + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_tags == [] + assert search( + f"Custom Policy {policy_arn} allows privilege escalation using the following actions: ", + result[0].status_extended, + ) + assert search( + "bedrock-agentcore:InvokeAgentRuntimeCommand", + result[0].status_extended, + ) + + @mock_aws + def test_iam_policy_allows_privilege_escalation_agentcore_passrole_create_runtime( + self, + ): + """Test detection of AgentCore Runtime privilege escalation by creating a new runtime with a passed role.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + policy_name = "agentcore_create_runtime_policy" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:PassRole", + "bedrock-agentcore:CreateAgentRuntime", + "bedrock-agentcore:CreateAgentRuntimeEndpoint", + "bedrock-agentcore:CreateWorkloadIdentity", + "bedrock-agentcore:InvokeAgentRuntimeCommand", + ], + "Resource": "*", + } + ], + } + + policy_arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import ( + iam_policy_allows_privilege_escalation, + ) + + check = iam_policy_allows_privilege_escalation() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == policy_name + assert result[0].resource_arn == policy_arn + assert search("iam:PassRole", result[0].status_extended) + assert search( + "bedrock-agentcore:CreateAgentRuntime", + result[0].status_extended, + ) + assert search( + "bedrock-agentcore:CreateAgentRuntimeEndpoint", + result[0].status_extended, + ) + assert search( + "bedrock-agentcore:CreateWorkloadIdentity", + result[0].status_extended, + ) + assert search( + "bedrock-agentcore:InvokeAgentRuntimeCommand", + result[0].status_extended, + ) + + @mock_aws + def test_iam_policy_allows_privilege_escalation_agentcore_passrole_create_harness( + self, + ): + """Test detection of AgentCore Harness privilege escalation by creating a new harness with a passed role.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + policy_name = "agentcore_create_harness_policy" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:PassRole", + "bedrock-agentcore:CreateHarness", + "bedrock-agentcore:CreateAgentRuntime", + "bedrock-agentcore:CreateAgentRuntimeEndpoint", + "bedrock-agentcore:CreateWorkloadIdentity", + "bedrock-agentcore:GetAgentRuntime", + "bedrock-agentcore:InvokeAgentRuntimeCommand", + ], + "Resource": "*", + } + ], + } + + policy_arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import ( + iam_policy_allows_privilege_escalation, + ) + + check = iam_policy_allows_privilege_escalation() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == policy_name + assert result[0].resource_arn == policy_arn + assert search("iam:PassRole", result[0].status_extended) + assert search("bedrock-agentcore:CreateHarness", result[0].status_extended) + assert search( + "bedrock-agentcore:CreateAgentRuntime", + result[0].status_extended, + ) + assert search( + "bedrock-agentcore:CreateAgentRuntimeEndpoint", + result[0].status_extended, + ) + assert search( + "bedrock-agentcore:CreateWorkloadIdentity", + result[0].status_extended, + ) + assert search( + "bedrock-agentcore:GetAgentRuntime", result[0].status_extended + ) + assert search( + "bedrock-agentcore:InvokeAgentRuntimeCommand", + result[0].status_extended, + ) + + @mock_aws + def test_iam_policy_allows_privilege_escalation_agentcore_browser_session_connect( + self, + ): + """Test detection of AgentCore Custom Browser privilege escalation via an existing browser session.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + policy_name = "agentcore_browser_session_connect_policy" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:StartBrowserSession", + "bedrock-agentcore:ConnectBrowserAutomationStream", + ], + "Resource": "*", + } + ], + } + + policy_arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import ( + iam_policy_allows_privilege_escalation, + ) + + check = iam_policy_allows_privilege_escalation() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == policy_name + assert result[0].resource_arn == policy_arn + assert search( + "bedrock-agentcore:StartBrowserSession", result[0].status_extended + ) + assert search( + "bedrock-agentcore:ConnectBrowserAutomationStream", + result[0].status_extended, + ) + + @mock_aws + def test_iam_policy_allows_privilege_escalation_agentcore_passrole_create_browser( + self, + ): + """Test detection of AgentCore Custom Browser privilege escalation by creating a new browser with a passed role.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + policy_name = "agentcore_create_browser_policy" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:PassRole", + "bedrock-agentcore:CreateBrowser", + "bedrock-agentcore:StartBrowserSession", + "bedrock-agentcore:ConnectBrowserAutomationStream", + ], + "Resource": "*", + } + ], + } + + policy_arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import ( + iam_policy_allows_privilege_escalation, + ) + + check = iam_policy_allows_privilege_escalation() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == policy_name + assert result[0].resource_arn == policy_arn + assert search("iam:PassRole", result[0].status_extended) + assert search("bedrock-agentcore:CreateBrowser", result[0].status_extended) + assert search( + "bedrock-agentcore:StartBrowserSession", result[0].status_extended + ) + assert search( + "bedrock-agentcore:ConnectBrowserAutomationStream", + result[0].status_extended, + ) + + @mock_aws + def test_iam_policy_allows_privilege_escalation_agentcore_wildcard( + self, + ): + """Test detection of AgentCore privilege escalation when the policy grants the bedrock-agentcore:* namespace wildcard.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + policy_name = "agentcore_wildcard_policy" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:*", + ], + "Resource": "*", + } + ], + } + + policy_arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import ( + iam_policy_allows_privilege_escalation, + ) + + check = iam_policy_allows_privilege_escalation() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == policy_name + assert result[0].resource_arn == policy_arn + assert search( + "bedrock-agentcore:InvokeAgentRuntimeCommand", + result[0].status_extended, + ) + assert search( + "bedrock-agentcore:StartCodeInterpreterSession", + result[0].status_extended, + ) + assert search( + "bedrock-agentcore:StartBrowserSession", result[0].status_extended + ) + @mock_aws def test_iam_policy_allows_privilege_escalation_iam_put( self, diff --git a/tests/providers/aws/services/inspector2/inspector2_is_enabled/inspector2_is_enabled_test.py b/tests/providers/aws/services/inspector2/inspector2_is_enabled/inspector2_is_enabled_test.py index 2ea7702cd3..66eb2ceaa3 100644 --- a/tests/providers/aws/services/inspector2/inspector2_is_enabled/inspector2_is_enabled_test.py +++ b/tests/providers/aws/services/inspector2/inspector2_is_enabled/inspector2_is_enabled_test.py @@ -1,3 +1,4 @@ +from types import SimpleNamespace from unittest import mock from prowler.providers.aws.services.inspector2.inspector2_service import Inspector @@ -13,6 +14,65 @@ FINDING_ARN = ( class Test_inspector2_is_enabled: + def test_lambda_disabled_with_region_hidden_by_function_analysis_limit(self): + inspector2_client = mock.MagicMock() + inspector2_client.provider = SimpleNamespace(scan_unused_services=False) + inspector2_client.inspectors = [ + Inspector( + id=AWS_ACCOUNT_NUMBER, + arn=f"arn:aws:inspector2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:inspector2", + status="ENABLED", + ec2_status="ENABLED", + ecr_status="ENABLED", + lambda_status="DISABLED", + lambda_code_status="ENABLED", + region=AWS_REGION_EU_WEST_1, + ) + ] + awslambda_client = mock.MagicMock() + awslambda_client.functions = {} + awslambda_client.regions_with_functions = {AWS_REGION_EU_WEST_1} + ec2_client = mock.MagicMock() + ec2_client.instances = [] + ecr_client = mock.MagicMock() + ecr_client.registries = {AWS_REGION_EU_WEST_1: SimpleNamespace(repositories=[])} + 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.inspector2.inspector2_is_enabled.inspector2_is_enabled.inspector2_client", + new=inspector2_client, + ), + mock.patch( + "prowler.providers.aws.services.inspector2.inspector2_is_enabled.inspector2_is_enabled.awslambda_client", + new=awslambda_client, + ), + mock.patch( + "prowler.providers.aws.services.inspector2.inspector2_is_enabled.inspector2_is_enabled.ec2_client", + new=ec2_client, + ), + mock.patch( + "prowler.providers.aws.services.inspector2.inspector2_is_enabled.inspector2_is_enabled.ecr_client", + new=ecr_client, + ), + ): + from prowler.providers.aws.services.inspector2.inspector2_is_enabled.inspector2_is_enabled import ( + inspector2_is_enabled, + ) + + result = inspector2_is_enabled().execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Inspector2 is not enabled for the following services: Lambda." + ) + def test_inspector2_disabled(self): # Mock the inspector2 client inspector2_client = mock.MagicMock diff --git a/tests/providers/aws/services/s3/s3_bucket_object_public/__init__.py b/tests/providers/aws/services/s3/s3_bucket_object_public/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/aws/services/s3/s3_bucket_object_public/s3_bucket_object_public_test.py b/tests/providers/aws/services/s3/s3_bucket_object_public/s3_bucket_object_public_test.py new file mode 100644 index 0000000000..2ac143ff47 --- /dev/null +++ b/tests/providers/aws/services/s3/s3_bucket_object_public/s3_bucket_object_public_test.py @@ -0,0 +1,302 @@ +from unittest import mock + +from boto3 import client +from botocore.exceptions import ClientError +from moto import mock_aws + +from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider + +CHECK_MODULE = ( + "prowler.providers.aws.services.s3.s3_bucket_object_public.s3_bucket_object_public" +) + +ENABLED_CONFIG = { + "s3_bucket_object_public_enabled": True, + "s3_bucket_object_public_max_objects": 100, + "s3_bucket_object_public_sample_size": 3, +} + + +class Test_s3_bucket_object_public: + @mock_aws + def test_check_disabled_by_default_returns_no_findings(self): + s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client_us_east_1.create_bucket(Bucket="bucket-disabled") + + from prowler.providers.aws.services.s3.s3_service import S3 + + # No audit_config -> check disabled by default + 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, + ): + s3_service = S3(aws_provider) + with mock.patch(f"{CHECK_MODULE}.s3_client", new=s3_service): + from prowler.providers.aws.services.s3.s3_bucket_object_public.s3_bucket_object_public import ( + s3_bucket_object_public, + ) + + check = s3_bucket_object_public() + result = check.execute() + + assert result == [] + + @mock_aws + def test_no_buckets_returns_no_findings(self): + from prowler.providers.aws.services.s3.s3_service import S3 + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], audit_config=ENABLED_CONFIG + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + s3_service = S3(aws_provider) + with mock.patch(f"{CHECK_MODULE}.s3_client", new=s3_service): + from prowler.providers.aws.services.s3.s3_bucket_object_public.s3_bucket_object_public import ( + s3_bucket_object_public, + ) + + check = s3_bucket_object_public() + result = check.execute() + + assert result == [] + + @mock_aws + def test_bucket_empty_passes(self): + s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1) + bucket_name = "bucket-empty" + s3_client_us_east_1.create_bucket(Bucket=bucket_name) + + from prowler.providers.aws.services.s3.s3_service import S3 + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], audit_config=ENABLED_CONFIG + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + s3_service = S3(aws_provider) + with mock.patch(f"{CHECK_MODULE}.s3_client", new=s3_service): + from prowler.providers.aws.services.s3.s3_bucket_object_public.s3_bucket_object_public import ( + s3_bucket_object_public, + ) + + check = s3_bucket_object_public() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"S3 Bucket {bucket_name} is empty." + ) + assert result[0].resource_id == bucket_name + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_bucket_with_only_private_objects_passes(self): + s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1) + bucket_name = "bucket-private-objects" + s3_client_us_east_1.create_bucket(Bucket=bucket_name) + s3_client_us_east_1.put_object( + Bucket=bucket_name, Key="private-1.txt", Body=b"x" + ) + s3_client_us_east_1.put_object( + Bucket=bucket_name, Key="private-2.txt", Body=b"x" + ) + + from prowler.providers.aws.services.s3.s3_service import S3 + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], audit_config=ENABLED_CONFIG + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + s3_service = S3(aws_provider) + with mock.patch(f"{CHECK_MODULE}.s3_client", new=s3_service): + from prowler.providers.aws.services.s3.s3_bucket_object_public.s3_bucket_object_public import ( + s3_bucket_object_public, + ) + + check = s3_bucket_object_public() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "No public objects detected in spot-check sample of" in ( + result[0].status_extended + ) + assert bucket_name in result[0].status_extended + assert ( + "For complete assurance, ensure ACLs are disabled via " + "Object Ownership settings." + ) in result[0].status_extended + + @mock_aws + def test_bucket_with_public_object_fails(self): + s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1) + bucket_name = "bucket-public-object" + public_key = "public.txt" + s3_client_us_east_1.create_bucket(Bucket=bucket_name) + s3_client_us_east_1.put_object(Bucket=bucket_name, Key=public_key, Body=b"x") + s3_client_us_east_1.put_object_acl( + Bucket=bucket_name, Key=public_key, ACL="public-read" + ) + + from prowler.providers.aws.services.s3.s3_service import S3 + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], audit_config=ENABLED_CONFIG + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + s3_service = S3(aws_provider) + with mock.patch(f"{CHECK_MODULE}.s3_client", new=s3_service): + from prowler.providers.aws.services.s3.s3_bucket_object_public.s3_bucket_object_public import ( + s3_bucket_object_public, + ) + + check = s3_bucket_object_public() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert public_key in result[0].status_extended + assert ( + f"S3 Bucket {bucket_name} has public objects detected in " + "spot-check sample of" + ) in result[0].status_extended + + @mock_aws + def test_bucket_with_authenticated_users_object_fails(self): + s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1) + bucket_name = "bucket-authenticated-object" + public_key = "authenticated.txt" + s3_client_us_east_1.create_bucket(Bucket=bucket_name) + s3_client_us_east_1.put_object(Bucket=bucket_name, Key=public_key, Body=b"x") + s3_client_us_east_1.put_object_acl( + Bucket=bucket_name, Key=public_key, ACL="authenticated-read" + ) + + from prowler.providers.aws.services.s3.s3_service import S3 + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], audit_config=ENABLED_CONFIG + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + s3_service = S3(aws_provider) + with mock.patch(f"{CHECK_MODULE}.s3_client", new=s3_service): + from prowler.providers.aws.services.s3.s3_bucket_object_public.s3_bucket_object_public import ( + s3_bucket_object_public, + ) + + check = s3_bucket_object_public() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert public_key in result[0].status_extended + + @mock_aws + def test_access_denied_on_list_objects_reports_manual(self): + s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1) + bucket_name = "bucket-access-denied" + s3_client_us_east_1.create_bucket(Bucket=bucket_name) + + from prowler.providers.aws.services.s3.s3_service import S3 + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], audit_config=ENABLED_CONFIG + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + s3_service = S3(aws_provider) + + # Simulate AccessDenied when sampling and re-run sampling for the bucket + regional_client = mock.MagicMock() + regional_client.region = AWS_REGION_US_EAST_1 + regional_client.list_objects_v2.side_effect = ClientError( + {"Error": {"Code": "AccessDenied", "Message": "denied"}}, + "ListObjectsV2", + ) + s3_service.regional_clients[AWS_REGION_US_EAST_1] = regional_client + bucket = next(iter(s3_service.buckets.values())) + bucket.object_sampling = None + s3_service._get_public_objects(bucket) + + with mock.patch(f"{CHECK_MODULE}.s3_client", new=s3_service): + from prowler.providers.aws.services.s3.s3_bucket_object_public.s3_bucket_object_public import ( + s3_bucket_object_public, + ) + + check = s3_bucket_object_public() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].status_extended == ( + f"Access Denied when spot-checking objects in bucket " + f"{bucket_name}." + ) + + @mock_aws + def test_other_client_error_reports_manual(self): + s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1) + bucket_name = "bucket-other-error" + s3_client_us_east_1.create_bucket(Bucket=bucket_name) + + from prowler.providers.aws.services.s3.s3_service import S3 + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], audit_config=ENABLED_CONFIG + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + s3_service = S3(aws_provider) + + regional_client = mock.MagicMock() + regional_client.region = AWS_REGION_US_EAST_1 + regional_client.list_objects_v2.side_effect = ClientError( + {"Error": {"Code": "InternalError", "Message": "boom"}}, + "ListObjectsV2", + ) + s3_service.regional_clients[AWS_REGION_US_EAST_1] = regional_client + bucket = next(iter(s3_service.buckets.values())) + bucket.object_sampling = None + s3_service._get_public_objects(bucket) + + with mock.patch(f"{CHECK_MODULE}.s3_client", new=s3_service): + from prowler.providers.aws.services.s3.s3_bucket_object_public.s3_bucket_object_public import ( + s3_bucket_object_public, + ) + + check = s3_bucket_object_public() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + f"Could not spot-check objects in bucket {bucket_name}" + ) in result[0].status_extended diff --git a/tests/providers/aws/services/s3/s3_service_test.py b/tests/providers/aws/services/s3/s3_service_test.py index 7dec192f2e..d6d9575a11 100644 --- a/tests/providers/aws/services/s3/s3_service_test.py +++ b/tests/providers/aws/services/s3/s3_service_test.py @@ -642,3 +642,47 @@ class Test_S3_Service: assert s3control.access_points[arn].public_access_block.ignore_public_acls assert s3control.access_points[arn].public_access_block.block_public_policy assert s3control.access_points[arn].public_access_block.restrict_public_buckets + + # Test S3 object ACL sampling is skipped unless the check is enabled + @mock_aws + def test_get_public_objects_disabled_by_default(self): + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + bucket_name = "test-bucket" + bucket_arn = f"arn:aws:s3:::{bucket_name}" + s3_client.create_bucket(Bucket=bucket_name) + s3_client.put_object(Bucket=bucket_name, Key="a.txt", Body=b"x") + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + s3 = S3(aws_provider) + + assert s3.buckets[bucket_arn].object_sampling is None + + # Test S3 object ACL sampling detects a public object when enabled + @mock_aws + def test_get_public_objects_detects_public_acl(self): + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + bucket_name = "test-bucket" + bucket_arn = f"arn:aws:s3:::{bucket_name}" + s3_client.create_bucket(Bucket=bucket_name) + s3_client.put_object(Bucket=bucket_name, Key="public.txt", Body=b"x") + s3_client.put_object_acl( + Bucket=bucket_name, Key="public.txt", ACL="public-read" + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], + audit_config={"s3_bucket_object_public_enabled": True}, + ) + s3 = S3(aws_provider) + + sampling = s3.buckets[bucket_arn].object_sampling + assert sampling is not None + assert sampling.performed is True + assert sampling.is_empty is False + assert len(sampling.objects) == 1 + assert sampling.objects[0].key == "public.txt" + assert any( + grantee.type == "Group" + and grantee.URI == "http://acs.amazonaws.com/groups/global/AllUsers" + for grantee in sampling.objects[0].grantees + ) diff --git a/tests/providers/aws/services/ssm/ssm_document_secrets/ssm_document_secrets_test.py b/tests/providers/aws/services/ssm/ssm_document_secrets/ssm_document_secrets_test.py index 24f0a1fdd1..9fc6de4762 100644 --- a/tests/providers/aws/services/ssm/ssm_document_secrets/ssm_document_secrets_test.py +++ b/tests/providers/aws/services/ssm/ssm_document_secrets/ssm_document_secrets_test.py @@ -27,13 +27,13 @@ class Test_ssm_documents_secrets: document_name = "test-document" document_arn = f"arn:aws:ssm:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:document/{document_name}" ssm_client.audited_account = AWS_ACCOUNT_NUMBER - ssm_client.audit_config = {"detect_secrets_plugins": None} + ssm_client.audit_config = {} ssm_client.documents = { document_name: Document( arn=document_arn, name=document_name, region=AWS_REGION_US_EAST_1, - content={"db_password": "test-password"}, + content={"db_password": "Tr0ub4dor3xKq9vLmZ"}, account_owners=[], ) } @@ -56,7 +56,7 @@ class Test_ssm_documents_secrets: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in SSM Document {document_name} -> Secret Keyword on line 2." + == f"Potential secret found in SSM Document {document_name} -> Generic Password on line 2." ) def test_document_no_secrets(self): diff --git a/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk_test.py b/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk_test.py new file mode 100644 index 0000000000..44ec8f03c9 --- /dev/null +++ b/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk_test.py @@ -0,0 +1,142 @@ +from datetime import datetime +from unittest.mock import patch + +import pytest +from moto import mock_aws + +from prowler.providers.aws.services.stepfunctions.stepfunctions_service import ( + EncryptionConfiguration, + EncryptionType, + StateMachine, + StepFunctions, +) +from tests.providers.aws.utils import set_mocked_aws_provider + +AWS_REGION_EU_WEST_1 = "eu-west-1" +STATE_MACHINE_ID = "state-machine-12345" +STATE_MACHINE_ARN = f"arn:aws:states:{AWS_REGION_EU_WEST_1}:123456789012:stateMachine:{STATE_MACHINE_ID}" +KMS_KEY_ARN = "arn:aws:kms:eu-west-1:123456789012:key/some-key-id" + + +def create_state_machine(name, encryption_configuration): + """Create a mock StateMachine instance for use in tests. + + Args: + name (str): The display name of the state machine. + encryption_configuration (Optional[EncryptionConfiguration]): The encryption + configuration to assign to the state machine, or None. + + Returns: + StateMachine: A StateMachine instance pre-populated with test constants. + """ + return StateMachine( + id=STATE_MACHINE_ID, + arn=STATE_MACHINE_ARN, + name=name, + region=AWS_REGION_EU_WEST_1, + encryption_configuration=encryption_configuration, + tags=[], + status="ACTIVE", + definition="{}", + role_arn="arn:aws:iam::123456789012:role/step-functions-role", + type="STANDARD", + creation_date=datetime.now(), + ) + + +@pytest.mark.parametrize( + "state_machines, expected_count, expected_status, expected_status_extended", + [ + # No state machines , no findings + ({}, 0, None, None), + # AWS-owned key (default) , FAIL + ( + { + STATE_MACHINE_ARN: create_state_machine( + "TestStateMachine", + EncryptionConfiguration( + type=EncryptionType.AWS_OWNED_KEY, + kms_key_id=None, + kms_data_key_reuse_period_seconds=None, + ), + ) + }, + 1, + "FAIL", + "Step Functions state machine TestStateMachine is not encrypted at rest with a customer-managed KMS key.", + ), + # No encryption configuration (None) , FAIL + ( + { + STATE_MACHINE_ARN: create_state_machine( + "TestStateMachine", + None, + ) + }, + 1, + "FAIL", + "Step Functions state machine TestStateMachine is not encrypted at rest with a customer-managed KMS key.", + ), + # Customer-managed KMS key , PASS + ( + { + STATE_MACHINE_ARN: create_state_machine( + "TestStateMachine", + EncryptionConfiguration( + type=EncryptionType.CUSTOMER_MANAGED_KMS_KEY, + kms_key_id=KMS_KEY_ARN, + kms_data_key_reuse_period_seconds=300, + ), + ) + }, + 1, + "PASS", + "Step Functions state machine TestStateMachine is encrypted at rest with a customer-managed KMS key.", + ), + ], +) +@mock_aws(config={"stepfunctions": {"execute_state_machine": True}}) +def test_stepfunctions_statemachine_encrypted_with_cmk( + state_machines, + expected_count, + expected_status, + expected_status_extended, +): + """Test stepfunctions_statemachine_encrypted_with_cmk check across multiple scenarios. + + Parametrized test cases cover: + - No state machines present (empty findings). + - State machine using the default AWS-owned key (FAIL). + - State machine with no encryption configuration set (FAIL). + - State machine using a customer-managed KMS key (PASS). + + Args: + state_machines (dict): Mapping of ARN to StateMachine used to mock the service client. + expected_count (int): Expected number of findings returned by the check. + expected_status (Optional[str]): Expected status of the finding, or None if no findings. + expected_status_extended (Optional[str]): Expected status_extended message, or None. + """ + mocked_aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + stepfunctions_client = StepFunctions(mocked_aws_provider) + stepfunctions_client.state_machines = state_machines + + with patch( + "prowler.providers.aws.services.stepfunctions.stepfunctions_statemachine_encrypted_with_cmk.stepfunctions_statemachine_encrypted_with_cmk.stepfunctions_client", + new=stepfunctions_client, + ): + from prowler.providers.aws.services.stepfunctions.stepfunctions_statemachine_encrypted_with_cmk.stepfunctions_statemachine_encrypted_with_cmk import ( + stepfunctions_statemachine_encrypted_with_cmk, + ) + + check = stepfunctions_statemachine_encrypted_with_cmk() + result = check.execute() + + assert len(result) == expected_count + + if expected_count == 1: + assert result[0].status == expected_status + assert result[0].status_extended == expected_status_extended + assert result[0].resource_id == STATE_MACHINE_ID + assert result[0].resource_arn == STATE_MACHINE_ARN + assert result[0].region == AWS_REGION_EU_WEST_1 + assert result[0].resource == state_machines[STATE_MACHINE_ARN] diff --git a/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/stepfunctions_statemachine_no_secrets_in_definition_test.py b/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/stepfunctions_statemachine_no_secrets_in_definition_test.py index 628525e542..1b1fa1bfca 100644 --- a/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/stepfunctions_statemachine_no_secrets_in_definition_test.py +++ b/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/stepfunctions_statemachine_no_secrets_in_definition_test.py @@ -147,7 +147,7 @@ class Test_stepfunctions_statemachine_no_secrets_in_definition: arn=statemachine_arn, name="TestStateMachine", status=StateMachineStatus.ACTIVE, - definition='{"Comment": "Example with secret", "StartAt": "MyTask", "States": {"MyTask": {"Type": "Task", "Parameters": {"api_key": "AKIAIOSFODNN7EXAMPLE"}, "End": true}}}', + definition='{"Comment": "Example with secret", "StartAt": "MyTask", "States": {"MyTask": {"Type": "Task", "Parameters": {"api_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"}, "End": true}}}', region=AWS_REGION_US_EAST_1, type=StateMachineType.STANDARD, creation_date=datetime.now(), diff --git a/tests/providers/aws/services/waf/waf_regional_webacl_logging_enabled/__init__.py b/tests/providers/aws/services/waf/waf_regional_webacl_logging_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/aws/services/waf/waf_regional_webacl_logging_enabled/waf_regional_webacl_logging_enabled_test.py b/tests/providers/aws/services/waf/waf_regional_webacl_logging_enabled/waf_regional_webacl_logging_enabled_test.py new file mode 100644 index 0000000000..14b71e8f52 --- /dev/null +++ b/tests/providers/aws/services/waf/waf_regional_webacl_logging_enabled/waf_regional_webacl_logging_enabled_test.py @@ -0,0 +1,199 @@ +from unittest import mock +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +WEB_ACL_ID = "test-web-acl-id" +WEB_ACL_NAME = "test-web-acl-name" +WEB_ACL_ARN = f"arn:aws:waf-regional:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:webacl/{WEB_ACL_ID}" +FIREHOSE_ARN = f"arn:aws:firehose:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:deliverystream/aws-waf-logs-regional" + +# Original botocore _make_api_call function +orig = botocore.client.BaseClient._make_api_call + + +def _base_waf_regional_calls(operation_name, kwarg): + """Return responses for WAFRegional API calls that are common across all test scenarios. + + Args: + operation_name (str): The name of the botocore operation being called. + kwarg (dict): The keyword arguments passed to the API call. + + Returns: + dict or None: The mocked API response if the operation is handled, otherwise None. + """ + unused_operations = [ + "ListRules", + "GetRule", + "ListRuleGroups", + "ListActivatedRulesInRuleGroup", + "ListResourcesForWebACL", + ] + if operation_name in unused_operations: + return {} + if operation_name == "GetChangeToken": + return {"ChangeToken": "my-change-token"} + if operation_name == "ListWebACLs": + return {"WebACLs": [{"WebACLId": WEB_ACL_ID, "Name": WEB_ACL_NAME}]} + if operation_name == "GetWebACL": + return {"WebACL": {"Rules": []}} + return None + + +def mock_make_api_call_logging_enabled(self, operation_name, kwarg): + """Mock botocore API calls with logging enabled on the Regional Web ACL. + + Args: + self: The botocore client instance. + operation_name (str): The name of the botocore operation being called. + kwarg (dict): The keyword arguments passed to the API call. + + Returns: + dict: The mocked API response. + """ + base = _base_waf_regional_calls(operation_name, kwarg) + if base is not None: + return base + if operation_name == "GetLoggingConfiguration": + return { + "LoggingConfiguration": { + "ResourceArn": WEB_ACL_ARN, + "LogDestinationConfigs": [FIREHOSE_ARN], + "RedactedFields": [], + "ManagedByFirewallManager": False, + } + } + return orig(self, operation_name, kwarg) + + +def mock_make_api_call_logging_disabled(self, operation_name, kwarg): + """Mock botocore API calls with logging disabled on the Regional Web ACL. + + Args: + self: The botocore client instance. + operation_name (str): The name of the botocore operation being called. + kwarg (dict): The keyword arguments passed to the API call. + + Returns: + dict: The mocked API response. + """ + base = _base_waf_regional_calls(operation_name, kwarg) + if base is not None: + return base + if operation_name == "GetLoggingConfiguration": + return { + "LoggingConfiguration": { + "ResourceArn": WEB_ACL_ARN, + "LogDestinationConfigs": [], + "RedactedFields": [], + "ManagedByFirewallManager": False, + } + } + return orig(self, operation_name, kwarg) + + +class Test_waf_regional_webacl_logging_enabled: + """Tests for the waf_regional_webacl_logging_enabled check.""" + + @mock_aws + def test_no_waf(self): + """Test that no findings are returned when no Regional Web ACLs exist.""" + from prowler.providers.aws.services.waf.waf_service import WAFRegional + + 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, + ): + with mock.patch( + "prowler.providers.aws.services.waf.waf_regional_webacl_logging_enabled.waf_regional_webacl_logging_enabled.wafregional_client", + new=WAFRegional(aws_provider), + ): + from prowler.providers.aws.services.waf.waf_regional_webacl_logging_enabled.waf_regional_webacl_logging_enabled import ( + waf_regional_webacl_logging_enabled, + ) + + check = waf_regional_webacl_logging_enabled() + result = check.execute() + + assert len(result) == 0 + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_logging_disabled, + ) + @mock_aws + def test_waf_regional_webacl_logging_disabled(self): + """Test that a FAIL finding is returned when logging is disabled on a Regional Web ACL.""" + from prowler.providers.aws.services.waf.waf_service import WAFRegional + + 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, + ): + with mock.patch( + "prowler.providers.aws.services.waf.waf_regional_webacl_logging_enabled.waf_regional_webacl_logging_enabled.wafregional_client", + new=WAFRegional(aws_provider), + ): + from prowler.providers.aws.services.waf.waf_regional_webacl_logging_enabled.waf_regional_webacl_logging_enabled import ( + waf_regional_webacl_logging_enabled, + ) + + check = waf_regional_webacl_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"AWS WAF Regional Web ACL {WEB_ACL_NAME} does not have logging enabled." + ) + assert result[0].resource_id == WEB_ACL_ID + assert result[0].resource_arn == WEB_ACL_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_logging_enabled, + ) + @mock_aws + def test_waf_regional_webacl_logging_enabled(self): + """Test that a PASS finding is returned when logging is enabled on a Regional Web ACL.""" + from prowler.providers.aws.services.waf.waf_service import WAFRegional + + 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, + ): + with mock.patch( + "prowler.providers.aws.services.waf.waf_regional_webacl_logging_enabled.waf_regional_webacl_logging_enabled.wafregional_client", + new=WAFRegional(aws_provider), + ): + from prowler.providers.aws.services.waf.waf_regional_webacl_logging_enabled.waf_regional_webacl_logging_enabled import ( + waf_regional_webacl_logging_enabled, + ) + + check = waf_regional_webacl_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"AWS WAF Regional Web ACL {WEB_ACL_NAME} does have logging enabled." + ) + assert result[0].resource_id == WEB_ACL_ID + assert result[0].resource_arn == WEB_ACL_ARN + assert result[0].region == AWS_REGION_US_EAST_1 diff --git a/tests/providers/azure/azure_fixtures.py b/tests/providers/azure/azure_fixtures.py index 84d43fd2c3..d095645e1f 100644 --- a/tests/providers/azure/azure_fixtures.py +++ b/tests/providers/azure/azure_fixtures.py @@ -9,6 +9,8 @@ from prowler.providers.azure.models import AzureIdentityInfo, AzureRegionConfig AZURE_SUBSCRIPTION_ID = str(uuid4()) AZURE_SUBSCRIPTION_NAME = "Subscription Name" AZURE_SUBSCRIPTION_DISPLAY = f"{AZURE_SUBSCRIPTION_NAME} ({AZURE_SUBSCRIPTION_ID})" +RESOURCE_GROUP = "rg" +RESOURCE_GROUP_LIST = [RESOURCE_GROUP, "rg2"] # Azure Identity IDENTITY_ID = "00000000-0000-0000-0000-000000000000" @@ -30,6 +32,7 @@ def set_mocked_azure_provider( audit_config: dict = None, azure_region_config: AzureRegionConfig = AzureRegionConfig(), locations: list = None, + resource_groups: dict = None, ) -> AzureProvider: provider = MagicMock() @@ -39,5 +42,6 @@ def set_mocked_azure_provider( provider.identity = identity provider.audit_config = audit_config provider.region_config = azure_region_config + provider.resource_groups = resource_groups return provider diff --git a/tests/providers/azure/azure_provider_test.py b/tests/providers/azure/azure_provider_test.py index 1d9aa97e6b..b4fba94691 100644 --- a/tests/providers/azure/azure_provider_test.py +++ b/tests/providers/azure/azure_provider_test.py @@ -552,6 +552,102 @@ class TestAzureProvider: assert regions == expected_regions +class TestAzureProviderValidateResourceGroups: + @patch( + "prowler.providers.azure.azure_provider.AzureProvider.__init__", + return_value=None, + ) + def _make_provider(self, _mock_init, subscriptions=None): + provider = AzureProvider() + provider._identity = MagicMock() + provider._identity.subscriptions = subscriptions or {str(uuid4()): "Sub"} + provider._session = MagicMock() + provider._region_config = MagicMock() + return provider + + @patch("prowler.providers.azure.azure_provider.ResourceManagementClient") + def test_validate_resource_groups_exact_match(self, mock_rm_client): + provider = self._make_provider() + sub_name = list(provider._identity.subscriptions.keys())[0] + + mock_rg = MagicMock() + mock_rg.name = "mygroup" + mock_resource_groups = MagicMock() + mock_resource_groups.list.return_value = [mock_rg] + mock_rm_client.return_value.resource_groups = mock_resource_groups + + result = provider.validate_resource_groups(["mygroup"]) + + assert result[sub_name] == ["mygroup"] + + @patch("prowler.providers.azure.azure_provider.ResourceManagementClient") + def test_validate_resource_groups_mixed_case(self, mock_rm_client): + provider = self._make_provider() + sub_name = list(provider._identity.subscriptions.keys())[0] + + mock_rg = MagicMock() + mock_rg.name = "MyGroup" + mock_resource_groups = MagicMock() + mock_resource_groups.list.return_value = [mock_rg] + mock_rm_client.return_value.resource_groups = mock_resource_groups + + result = provider.validate_resource_groups(["mygroup"]) + + assert result[sub_name] == ["MyGroup"] + mock_resource_groups.list.assert_called_once() + + @patch("prowler.providers.azure.azure_provider.ResourceManagementClient") + def test_validate_resource_groups_multiple_rgs(self, mock_rm_client): + provider = self._make_provider() + sub_name = list(provider._identity.subscriptions.keys())[0] + + rg1, rg2 = MagicMock(), MagicMock() + rg1.name = "rg1" + rg2.name = "rg2" + mock_resource_groups = MagicMock() + mock_resource_groups.list.return_value = [rg1, rg2] + mock_rm_client.return_value.resource_groups = mock_resource_groups + + result = provider.validate_resource_groups(["rg1", "rg2"]) + + assert set(result[sub_name]) == {"rg1", "rg2"} + + @patch("prowler.providers.azure.azure_provider.ResourceManagementClient") + def test_validate_resource_groups_not_found(self, mock_rm_client): + provider = self._make_provider() + sub_name = list(provider._identity.subscriptions.keys())[0] + + mock_rg = MagicMock() + mock_rg.name = "existing" + mock_resource_groups = MagicMock() + mock_resource_groups.list.return_value = [mock_rg] + mock_rm_client.return_value.resource_groups = mock_resource_groups + + result = provider.validate_resource_groups(["nonexistent"]) + + assert result[sub_name] == [] + + def test_validate_resource_groups_empty_input(self): + provider = self._make_provider() + result = provider.validate_resource_groups([]) + assert result == {} + + @patch("prowler.providers.azure.azure_provider.ResourceManagementClient") + def test_validate_resource_groups_strips_whitespace(self, mock_rm_client): + provider = self._make_provider() + sub_name = list(provider._identity.subscriptions.keys())[0] + + mock_rg = MagicMock() + mock_rg.name = "rg-prod" + mock_resource_groups = MagicMock() + mock_resource_groups.list.return_value = [mock_rg] + mock_rm_client.return_value.resource_groups = mock_resource_groups + + result = provider.validate_resource_groups([" rg-prod "]) + + assert result[sub_name] == ["rg-prod"] + + class TestAzureProviderSetupIdentitySubscriptions: """Regression tests ensuring identity.subscriptions preserves every subscription even when multiple Azure subscriptions share the same diff --git a/tests/providers/azure/services/aisearch/aisearch_service_test.py b/tests/providers/azure/services/aisearch/aisearch_service_test.py index ff041e7eab..e8bc94675f 100644 --- a/tests/providers/azure/services/aisearch/aisearch_service_test.py +++ b/tests/providers/azure/services/aisearch/aisearch_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.azure.services.aisearch.aisearch_service import ( AISearch, @@ -6,9 +6,13 @@ from prowler.providers.azure.services.aisearch.aisearch_service import ( ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) +AISEARCH_SERVICE_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}/providers/Microsoft.Search/searchServices/search1" + def mock_storage_get_aisearch_services(_): return { @@ -58,3 +62,121 @@ class Test_AISearch_Service: assert aisearch.aisearch_services[AZURE_SUBSCRIPTION_ID][ "aisearch_service_id-1" ].public_network_access + + +class Test_AISearch_Service_get_aisearch_services: + def test_get_aisearch_services_no_resource_groups(self): + mock_service = MagicMock() + mock_service.id = AISEARCH_SERVICE_ID + mock_service.name = "search1" + mock_service.location = "westeurope" + mock_service.public_network_access = "Enabled" + + mock_client = MagicMock() + mock_client.services.list_by_subscription.return_value = [mock_service] + + with patch( + "prowler.providers.azure.services.aisearch.aisearch_service.AISearch._get_aisearch_services", + return_value={}, + ): + aisearch = AISearch(set_mocked_azure_provider()) + + aisearch.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + aisearch.resource_groups = None + + result = aisearch._get_aisearch_services() + + mock_client.services.list_by_subscription.assert_called_once() + mock_client.services.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + assert ( + result[AZURE_SUBSCRIPTION_ID][AISEARCH_SERVICE_ID].public_network_access + is True + ) + + def test_get_aisearch_services_with_resource_group(self): + mock_service = MagicMock() + mock_service.id = AISEARCH_SERVICE_ID + mock_service.name = "search1" + mock_service.location = "westeurope" + mock_service.public_network_access = "Disabled" + + mock_client = MagicMock() + mock_client.services.list_by_resource_group.return_value = [mock_service] + + with patch( + "prowler.providers.azure.services.aisearch.aisearch_service.AISearch._get_aisearch_services", + return_value={}, + ): + aisearch = AISearch(set_mocked_azure_provider()) + + aisearch.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + aisearch.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = aisearch._get_aisearch_services() + + mock_client.services.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.services.list_by_subscription.assert_not_called() + assert ( + result[AZURE_SUBSCRIPTION_ID][AISEARCH_SERVICE_ID].public_network_access + is False + ) + + def test_get_aisearch_services_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + + with patch( + "prowler.providers.azure.services.aisearch.aisearch_service.AISearch._get_aisearch_services", + return_value={}, + ): + aisearch = AISearch(set_mocked_azure_provider()) + + aisearch.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + aisearch.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = aisearch._get_aisearch_services() + + mock_client.services.list_by_resource_group.assert_not_called() + mock_client.services.list_by_subscription.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + def test_get_aisearch_services_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.services = MagicMock() + mock_client.services.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.aisearch.aisearch_service.AISearch._get_aisearch_services", + return_value={}, + ): + aisearch = AISearch(set_mocked_azure_provider()) + + aisearch.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + aisearch.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = aisearch._get_aisearch_services() + + assert mock_client.services.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_aisearch_services_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.services = MagicMock() + mock_client.services.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.aisearch.aisearch_service.AISearch._get_aisearch_services", + return_value={}, + ): + aisearch = AISearch(set_mocked_azure_provider()) + + aisearch.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + aisearch.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + aisearch._get_aisearch_services() + + mock_client.services.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/azure/services/aks/aks_service_test.py b/tests/providers/azure/services/aks/aks_service_test.py index 8644dcb03b..494c224b6b 100644 --- a/tests/providers/azure/services/aks/aks_service_test.py +++ b/tests/providers/azure/services/aks/aks_service_test.py @@ -1,8 +1,10 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.azure.services.aks.aks_service import AKS, Cluster from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -66,3 +68,128 @@ class Test_AKS_Service: aks.clusters[AZURE_SUBSCRIPTION_ID]["cluster_id-1"].location == "westeurope" ) assert aks.clusters[AZURE_SUBSCRIPTION_ID]["cluster_id-1"].rbac_enabled + + +class Test_AKS_get_clusters: + def test_get_clusters_no_resource_groups(self): + mock_cluster = MagicMock() + mock_cluster.id = "cluster_id-1" + mock_cluster.name = "cluster_name" + mock_cluster.fqdn = "public_fqdn" + mock_cluster.private_fqdn = "private_fqdn" + mock_cluster.location = "westeurope" + mock_cluster.kubernetes_version = "1.28.0" + mock_cluster.network_profile = None + mock_cluster.agent_pool_profiles = [] + mock_cluster.enable_rbac = False + + mock_client = MagicMock() + mock_client.managed_clusters.list.return_value = [mock_cluster] + + with patch( + "prowler.providers.azure.services.aks.aks_service.AKS._get_clusters", + return_value={}, + ): + aks = AKS(set_mocked_azure_provider()) + + aks.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + aks.resource_groups = None + + result = aks._get_clusters() + + mock_client.managed_clusters.list.assert_called_once() + mock_client.managed_clusters.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + assert "cluster_id-1" in result[AZURE_SUBSCRIPTION_ID] + + def test_get_clusters_with_resource_group(self): + mock_cluster = MagicMock() + mock_cluster.id = "cluster_id-1" + mock_cluster.name = "cluster_name" + mock_cluster.fqdn = "public_fqdn" + mock_cluster.private_fqdn = "private_fqdn" + mock_cluster.location = "westeurope" + mock_cluster.kubernetes_version = "1.28.0" + mock_cluster.network_profile = None + mock_cluster.agent_pool_profiles = [] + mock_cluster.enable_rbac = False + + mock_client = MagicMock() + mock_client.managed_clusters.list_by_resource_group.return_value = [ + mock_cluster + ] + + with patch( + "prowler.providers.azure.services.aks.aks_service.AKS._get_clusters", + return_value={}, + ): + aks = AKS(set_mocked_azure_provider()) + + aks.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + aks.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = aks._get_clusters() + + mock_client.managed_clusters.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.managed_clusters.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + assert "cluster_id-1" in result[AZURE_SUBSCRIPTION_ID] + + def test_get_clusters_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + + with patch( + "prowler.providers.azure.services.aks.aks_service.AKS._get_clusters", + return_value={}, + ): + aks = AKS(set_mocked_azure_provider()) + + aks.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + aks.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = aks._get_clusters() + + mock_client.managed_clusters.list_by_resource_group.assert_not_called() + mock_client.managed_clusters.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + def test_get_clusters_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.managed_clusters = MagicMock() + mock_client.managed_clusters.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.aks.aks_service.AKS._get_clusters", + return_value={}, + ): + aks = AKS(set_mocked_azure_provider()) + + aks.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + aks.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = aks._get_clusters() + + assert mock_client.managed_clusters.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_clusters_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.managed_clusters = MagicMock() + mock_client.managed_clusters.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.aks.aks_service.AKS._get_clusters", + return_value={}, + ): + aks = AKS(set_mocked_azure_provider()) + + aks.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + aks.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + aks._get_clusters() + + mock_client.managed_clusters.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/azure/services/apim/apim_service_test.py b/tests/providers/azure/services/apim/apim_service_test.py index f2141aee6b..cb78225e99 100644 --- a/tests/providers/azure/services/apim/apim_service_test.py +++ b/tests/providers/azure/services/apim/apim_service_test.py @@ -1,6 +1,6 @@ from datetime import timedelta from unittest import TestCase, mock -from unittest.mock import patch +from unittest.mock import MagicMock, patch from azure.mgmt.loganalytics.models import Workspace from azure.mgmt.monitor.models import DiagnosticSettingsResource @@ -9,6 +9,8 @@ from azure.monitor.query import LogsQueryResult from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, AZURE_SUBSCRIPTION_NAME, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -16,7 +18,6 @@ from tests.providers.azure.azure_fixtures import ( APIM_INSTANCE_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg/providers/Microsoft.ApiManagement/service/apim1" APIM_INSTANCE_NAME = "apim1" LOCATION = "West US" -RESOURCE_GROUP = "rg" WORKSPACE_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourcegroups/rg/providers/microsoft.operationalinsights/workspaces/loganalytics" WORKSPACE_CUSTOMER_ID = "12345678-1234-1234-1234-1234567890ab" @@ -323,3 +324,168 @@ class Test_APIM_Service(TestCase): instance = apim.instances[AZURE_SUBSCRIPTION_ID][0] result = apim.get_llm_operations_logs(AZURE_SUBSCRIPTION_ID, instance) self.assertEqual(result, [{"log": "data"}]) + + +class Test_APIM_get_instances: + def test_get_instances_no_resource_groups(self): + mock_instance = MagicMock() + mock_instance.id = APIM_INSTANCE_ID + mock_instance.name = APIM_INSTANCE_NAME + mock_instance.location = LOCATION + + mock_client = MagicMock() + mock_client.api_management_service.list.return_value = [mock_instance] + + mock_provider = mock.MagicMock() + mock_provider.identity = mock.MagicMock() + with ( + patch( + "prowler.providers.azure.azure_provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.apim.apim_service.APIM._get_instances", + return_value={}, + ), + ): + from prowler.providers.azure.services.apim.apim_service import APIM + + apim = APIM(set_mocked_azure_provider()) + + apim.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + apim.resource_groups = None + + with patch.object(apim, "_get_log_analytics_workspace_id", return_value=None): + result = apim._get_instances() + + mock_client.api_management_service.list.assert_called_once() + mock_client.api_management_service.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + assert len(result[AZURE_SUBSCRIPTION_ID]) == 1 + assert result[AZURE_SUBSCRIPTION_ID][0].id == APIM_INSTANCE_ID + + def test_get_instances_with_resource_group(self): + mock_instance = MagicMock() + mock_instance.id = APIM_INSTANCE_ID + mock_instance.name = APIM_INSTANCE_NAME + mock_instance.location = LOCATION + + mock_client = MagicMock() + mock_client.api_management_service.list_by_resource_group.return_value = [ + mock_instance + ] + + mock_provider = mock.MagicMock() + mock_provider.identity = mock.MagicMock() + with ( + patch( + "prowler.providers.azure.azure_provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.apim.apim_service.APIM._get_instances", + return_value={}, + ), + ): + from prowler.providers.azure.services.apim.apim_service import APIM + + apim = APIM(set_mocked_azure_provider()) + + apim.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + apim.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + with patch.object(apim, "_get_log_analytics_workspace_id", return_value=None): + result = apim._get_instances() + + mock_client.api_management_service.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.api_management_service.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + assert len(result[AZURE_SUBSCRIPTION_ID]) == 1 + assert result[AZURE_SUBSCRIPTION_ID][0].name == APIM_INSTANCE_NAME + + def test_get_instances_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + + mock_provider = mock.MagicMock() + mock_provider.identity = mock.MagicMock() + with ( + patch( + "prowler.providers.azure.azure_provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.apim.apim_service.APIM._get_instances", + return_value={}, + ), + ): + from prowler.providers.azure.services.apim.apim_service import APIM + + apim = APIM(set_mocked_azure_provider()) + + apim.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + apim.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = apim._get_instances() + + mock_client.api_management_service.list_by_resource_group.assert_not_called() + mock_client.api_management_service.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == [] + + def test_get_instances_with_multiple_resource_groups(self): + mock_client = MagicMock() + + mock_provider = mock.MagicMock() + mock_provider.identity = mock.MagicMock() + with ( + patch( + "prowler.providers.azure.azure_provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.apim.apim_service.APIM._get_instances", + return_value={}, + ), + ): + from prowler.providers.azure.services.apim.apim_service import APIM + + apim = APIM(set_mocked_azure_provider()) + + apim.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + apim.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + with patch.object(apim, "_get_log_analytics_workspace_id", return_value=None): + result = apim._get_instances() + + assert mock_client.api_management_service.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_instances_with_mixed_case_resource_group(self): + mock_client = MagicMock() + + mock_provider = mock.MagicMock() + mock_provider.identity = mock.MagicMock() + with ( + patch( + "prowler.providers.azure.azure_provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.apim.apim_service.APIM._get_instances", + return_value={}, + ), + ): + from prowler.providers.azure.services.apim.apim_service import APIM + + apim = APIM(set_mocked_azure_provider()) + + apim.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + apim.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + with patch.object(apim, "_get_log_analytics_workspace_id", return_value=None): + apim._get_instances() + + mock_client.api_management_service.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/azure/services/app/app_service_test.py b/tests/providers/azure/services/app/app_service_test.py index cc33c662a1..8cbc5ad54f 100644 --- a/tests/providers/azure/services/app/app_service_test.py +++ b/tests/providers/azure/services/app/app_service_test.py @@ -5,6 +5,8 @@ from azure.mgmt.web.models import ManagedServiceIdentity, SiteConfigResource from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -244,3 +246,279 @@ class Test_App_Service: ].name == "functionapp-1" ) + + +class Test_App_get_apps: + def test_get_apps_no_resource_groups(self): + mock_client = MagicMock() + mock_client.web_apps.list.return_value = [] + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + ): + from prowler.providers.azure.services.app.app_service import App + + app = App(set_mocked_azure_provider()) + + app.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + app.resource_groups = None + + result = app._get_apps() + + mock_client.web_apps.list.assert_called_once() + mock_client.web_apps.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_apps_with_resource_group(self): + mock_client = MagicMock() + mock_client.web_apps.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + ): + from prowler.providers.azure.services.app.app_service import App + + app = App(set_mocked_azure_provider()) + + app.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + app.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = app._get_apps() + + mock_client.web_apps.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.web_apps.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_apps_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + ): + from prowler.providers.azure.services.app.app_service import App + + app = App(set_mocked_azure_provider()) + + app.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + app.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = app._get_apps() + + mock_client.web_apps.list_by_resource_group.assert_not_called() + mock_client.web_apps.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + +class Test_App_get_functions: + def test_get_functions_no_resource_groups(self): + mock_client = MagicMock() + mock_client.web_apps.list.return_value = [] + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + ): + from prowler.providers.azure.services.app.app_service import App + + app = App(set_mocked_azure_provider()) + + app.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + app.resource_groups = None + + result = app._get_functions() + + mock_client.web_apps.list.assert_called_once() + mock_client.web_apps.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_functions_with_resource_group(self): + mock_client = MagicMock() + mock_client.web_apps.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + ): + from prowler.providers.azure.services.app.app_service import App + + app = App(set_mocked_azure_provider()) + + app.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + app.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = app._get_functions() + + mock_client.web_apps.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.web_apps.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_functions_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + ): + from prowler.providers.azure.services.app.app_service import App + + app = App(set_mocked_azure_provider()) + + app.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + app.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = app._get_functions() + + mock_client.web_apps.list_by_resource_group.assert_not_called() + mock_client.web_apps.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + def test_get_apps_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.web_apps.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + ): + from prowler.providers.azure.services.app.app_service import App + + app = App(set_mocked_azure_provider()) + + app.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + app.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = app._get_apps() + + assert mock_client.web_apps.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_apps_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.web_apps.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + ): + from prowler.providers.azure.services.app.app_service import App + + app = App(set_mocked_azure_provider()) + + app.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + app.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + app._get_apps() + + mock_client.web_apps.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) + + +class Test_App_get_functions_extra: + def test_get_functions_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.web_apps.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + ): + from prowler.providers.azure.services.app.app_service import App + + app = App(set_mocked_azure_provider()) + + app.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + app.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = app._get_functions() + + assert mock_client.web_apps.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_functions_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.web_apps.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + ): + from prowler.providers.azure.services.app.app_service import App + + app = App(set_mocked_azure_provider()) + + app.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + app.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + app._get_functions() + + mock_client.web_apps.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/azure/services/appinsights/appinsights_service_test.py b/tests/providers/azure/services/appinsights/appinsights_service_test.py index 6d821ff3e6..7a0c80acc6 100644 --- a/tests/providers/azure/services/appinsights/appinsights_service_test.py +++ b/tests/providers/azure/services/appinsights/appinsights_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.azure.services.appinsights.appinsights_service import ( AppInsights, @@ -6,6 +6,8 @@ from prowler.providers.azure.services.appinsights.appinsights_service import ( ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -54,3 +56,121 @@ class Test_AppInsights_Service: appinsights.components[AZURE_SUBSCRIPTION_ID]["app_id-1"].location == "westeurope" ) + + +class Test_AppInsights_get_components: + def test_get_components_no_resource_groups(self): + mock_component = MagicMock() + mock_component.app_id = "comp-app-id" + mock_component.id = "/subscriptions/sub/rg/appinsights" + mock_component.name = "ai-component" + mock_component.location = "westeurope" + mock_component.instrumentation_key = "ikey-123" + + mock_client = MagicMock() + mock_client.components = MagicMock() + mock_client.components.list.return_value = [mock_component] + + with patch( + "prowler.providers.azure.services.appinsights.appinsights_service.AppInsights._get_components", + return_value={}, + ): + app_insights = AppInsights(set_mocked_azure_provider()) + + app_insights.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + app_insights.resource_groups = None + + result = app_insights._get_components() + + mock_client.components.list.assert_called_once() + mock_client.components.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + assert "comp-app-id" in result[AZURE_SUBSCRIPTION_ID] + + def test_get_components_with_resource_group(self): + mock_component = MagicMock() + mock_component.app_id = "comp-app-id" + mock_component.id = "/subscriptions/sub/rg/appinsights" + mock_component.name = "ai-component" + mock_component.location = "westeurope" + mock_component.instrumentation_key = "ikey-123" + + mock_client = MagicMock() + mock_client.components = MagicMock() + mock_client.components.list_by_resource_group.return_value = [mock_component] + + with patch( + "prowler.providers.azure.services.appinsights.appinsights_service.AppInsights._get_components", + return_value={}, + ): + app_insights = AppInsights(set_mocked_azure_provider()) + + app_insights.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + app_insights.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = app_insights._get_components() + + mock_client.components.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.components.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + assert "comp-app-id" in result[AZURE_SUBSCRIPTION_ID] + + def test_get_components_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.components = MagicMock() + + with patch( + "prowler.providers.azure.services.appinsights.appinsights_service.AppInsights._get_components", + return_value={}, + ): + app_insights = AppInsights(set_mocked_azure_provider()) + + app_insights.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + app_insights.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = app_insights._get_components() + + mock_client.components.list_by_resource_group.assert_not_called() + mock_client.components.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + def test_get_components_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.components = MagicMock() + mock_client.components.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.appinsights.appinsights_service.AppInsights._get_components", + return_value={}, + ): + app_insights = AppInsights(set_mocked_azure_provider()) + + app_insights.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + app_insights.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = app_insights._get_components() + + assert mock_client.components.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_components_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.components = MagicMock() + mock_client.components.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.appinsights.appinsights_service.AppInsights._get_components", + return_value={}, + ): + app_insights = AppInsights(set_mocked_azure_provider()) + + app_insights.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + app_insights.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + app_insights._get_components() + + mock_client.components.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/azure/services/containerregistry/containerregistry_service_test.py b/tests/providers/azure/services/containerregistry/containerregistry_service_test.py index 3e4a02406e..c3468ca086 100644 --- a/tests/providers/azure/services/containerregistry/containerregistry_service_test.py +++ b/tests/providers/azure/services/containerregistry/containerregistry_service_test.py @@ -3,6 +3,8 @@ from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -89,3 +91,208 @@ class TestContainerRegistryService: assert monitor_setting["logs"][0]["enabled"] is True assert monitor_setting["logs"][1]["category"] == "AdminLogs" assert monitor_setting["logs"][1]["enabled"] is False + + +class Test_ContainerRegistry_get_registries: + def test_get_container_registries_no_resource_groups(self): + from unittest.mock import MagicMock, patch + + mock_client = MagicMock() + mock_client.registries.list.return_value = [] + + mock_provider = MagicMock() + mock_provider.identity = MagicMock() + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + patch( + "prowler.providers.azure.services.containerregistry.containerregistry_service.ContainerRegistry._get_container_registries", + return_value={}, + ), + ): + from prowler.providers.azure.services.containerregistry.containerregistry_service import ( + ContainerRegistry, + ) + + cr = ContainerRegistry(set_mocked_azure_provider()) + + cr.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + cr.resource_groups = None + + with patch( + "prowler.providers.azure.services.containerregistry.containerregistry_service.monitor_client" + ): + result = cr._get_container_registries() + + mock_client.registries.list.assert_called_once() + mock_client.registries.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_container_registries_with_resource_group(self): + from unittest.mock import MagicMock, patch + + mock_client = MagicMock() + mock_client.registries.list_by_resource_group.return_value = [] + + mock_provider = MagicMock() + mock_provider.identity = MagicMock() + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + patch( + "prowler.providers.azure.services.containerregistry.containerregistry_service.ContainerRegistry._get_container_registries", + return_value={}, + ), + ): + from prowler.providers.azure.services.containerregistry.containerregistry_service import ( + ContainerRegistry, + ) + + cr = ContainerRegistry(set_mocked_azure_provider()) + + cr.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + cr.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + with patch( + "prowler.providers.azure.services.containerregistry.containerregistry_service.monitor_client" + ): + result = cr._get_container_registries() + + mock_client.registries.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.registries.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_container_registries_empty_resource_group_for_subscription(self): + from unittest.mock import MagicMock, patch + + mock_client = MagicMock() + + mock_provider = MagicMock() + mock_provider.identity = MagicMock() + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + patch( + "prowler.providers.azure.services.containerregistry.containerregistry_service.ContainerRegistry._get_container_registries", + return_value={}, + ), + ): + from prowler.providers.azure.services.containerregistry.containerregistry_service import ( + ContainerRegistry, + ) + + cr = ContainerRegistry(set_mocked_azure_provider()) + + cr.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + cr.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + with patch( + "prowler.providers.azure.services.containerregistry.containerregistry_service.monitor_client" + ): + result = cr._get_container_registries() + + mock_client.registries.list_by_resource_group.assert_not_called() + mock_client.registries.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + def test_get_container_registries_with_multiple_resource_groups(self): + from unittest.mock import MagicMock, patch + + mock_client = MagicMock() + mock_client.registries.list_by_resource_group.return_value = [] + + mock_provider = MagicMock() + mock_provider.identity = MagicMock() + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + patch( + "prowler.providers.azure.services.containerregistry.containerregistry_service.ContainerRegistry._get_container_registries", + return_value={}, + ), + ): + from prowler.providers.azure.services.containerregistry.containerregistry_service import ( + ContainerRegistry, + ) + + cr = ContainerRegistry(set_mocked_azure_provider()) + + cr.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + cr.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + with patch( + "prowler.providers.azure.services.containerregistry.containerregistry_service.monitor_client" + ): + result = cr._get_container_registries() + + assert mock_client.registries.list_by_resource_group.call_count == len( + RESOURCE_GROUP_LIST + ) + mock_client.registries.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_container_registries_with_mixed_case_resource_group(self): + from unittest.mock import MagicMock, patch + + mock_client = MagicMock() + mock_client.registries.list_by_resource_group.return_value = [] + + mock_provider = MagicMock() + mock_provider.identity = MagicMock() + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + patch( + "prowler.providers.azure.services.containerregistry.containerregistry_service.ContainerRegistry._get_container_registries", + return_value={}, + ), + ): + from prowler.providers.azure.services.containerregistry.containerregistry_service import ( + ContainerRegistry, + ) + + cr = ContainerRegistry(set_mocked_azure_provider()) + + cr.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + cr.resource_groups = {AZURE_SUBSCRIPTION_ID: ["MyRegistry-RG"]} + + with patch( + "prowler.providers.azure.services.containerregistry.containerregistry_service.monitor_client" + ): + cr._get_container_registries() + + mock_client.registries.list_by_resource_group.assert_called_once_with( + resource_group_name="MyRegistry-RG" + ) diff --git a/tests/providers/azure/services/cosmosdb/cosmosdb_service_test.py b/tests/providers/azure/services/cosmosdb/cosmosdb_service_test.py index 09293d7dcd..b1cc8d0e1b 100644 --- a/tests/providers/azure/services/cosmosdb/cosmosdb_service_test.py +++ b/tests/providers/azure/services/cosmosdb/cosmosdb_service_test.py @@ -1,8 +1,10 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.azure.services.cosmosdb.cosmosdb_service import Account, CosmosDB from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -133,3 +135,114 @@ class Test_CosmosDB_Service_None_Handling: == "Microsoft.Network/privateEndpoints" ) assert account.disable_local_auth is True + + +class Test_CosmosDB_get_accounts: + def test_get_accounts_no_resource_groups(self): + mock_client = MagicMock() + mock_client.database_accounts.list.return_value = [] + + with patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_service.CosmosDB._get_accounts", + return_value={}, + ): + cosmosdb = CosmosDB(set_mocked_azure_provider()) + + cosmosdb.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + cosmosdb.resource_groups = None + + result = cosmosdb._get_accounts() + + mock_client.database_accounts.list.assert_called_once() + mock_client.database_accounts.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_accounts_with_resource_group(self): + mock_account = MagicMock() + mock_account.id = "account-id" + mock_account.name = "my-cosmos" + mock_account.kind = "GlobalDocumentDB" + mock_account.location = "eastus" + mock_account.type = "Microsoft.DocumentDB/databaseAccounts" + mock_account.tags = {} + mock_account.is_virtual_network_filter_enabled = False + mock_account.private_endpoint_connections = [] + mock_account.disable_local_auth = False + + mock_client = MagicMock() + mock_client.database_accounts.list_by_resource_group.return_value = [ + mock_account + ] + + with patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_service.CosmosDB._get_accounts", + return_value={}, + ): + cosmosdb = CosmosDB(set_mocked_azure_provider()) + + cosmosdb.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + cosmosdb.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = cosmosdb._get_accounts() + + mock_client.database_accounts.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.database_accounts.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + assert len(result[AZURE_SUBSCRIPTION_ID]) == 1 + + def test_get_accounts_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + + with patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_service.CosmosDB._get_accounts", + return_value={}, + ): + cosmosdb = CosmosDB(set_mocked_azure_provider()) + + cosmosdb.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + cosmosdb.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = cosmosdb._get_accounts() + + mock_client.database_accounts.list_by_resource_group.assert_not_called() + mock_client.database_accounts.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == [] + + def test_get_accounts_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.database_accounts.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_service.CosmosDB._get_accounts", + return_value={}, + ): + cosmosdb = CosmosDB(set_mocked_azure_provider()) + + cosmosdb.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + cosmosdb.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = cosmosdb._get_accounts() + + assert mock_client.database_accounts.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_accounts_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.database_accounts.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_service.CosmosDB._get_accounts", + return_value={}, + ): + cosmosdb = CosmosDB(set_mocked_azure_provider()) + + cosmosdb.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + cosmosdb.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + cosmosdb._get_accounts() + + mock_client.database_accounts.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/azure/services/databricks/databricks_service_test.py b/tests/providers/azure/services/databricks/databricks_service_test.py index f663d81fe2..669558f0e0 100644 --- a/tests/providers/azure/services/databricks/databricks_service_test.py +++ b/tests/providers/azure/services/databricks/databricks_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.azure.services.databricks.databricks_service import ( Databricks, @@ -7,6 +7,8 @@ from prowler.providers.azure.services.databricks.databricks_service import ( ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -94,3 +96,123 @@ class Test_Databricks_Service_No_Encryption: assert workspace.location == "eastus" assert workspace.custom_managed_vnet_id == "test-vnet-id" assert workspace.managed_disk_encryption is None + + +class Test_Databricks_get_workspaces: + def test_get_workspaces_no_resource_groups(self): + mock_workspace = MagicMock() + mock_workspace.id = "ws-id-1" + mock_workspace.name = "my-workspace" + mock_workspace.location = "eastus" + mock_workspace.parameters = None + mock_workspace.encryption = None + mock_workspace.public_network_access = None + + mock_client = MagicMock() + mock_client.workspaces = MagicMock() + mock_client.workspaces.list_by_subscription.return_value = [mock_workspace] + + with patch( + "prowler.providers.azure.services.databricks.databricks_service.Databricks._get_workspaces", + return_value={}, + ): + databricks = Databricks(set_mocked_azure_provider()) + + databricks.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + databricks.resource_groups = None + + result = databricks._get_workspaces() + + mock_client.workspaces.list_by_subscription.assert_called_once() + mock_client.workspaces.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + assert "ws-id-1" in result[AZURE_SUBSCRIPTION_ID] + + def test_get_workspaces_with_resource_group(self): + mock_workspace = MagicMock() + mock_workspace.id = "ws-id-1" + mock_workspace.name = "my-workspace" + mock_workspace.location = "eastus" + mock_workspace.parameters = None + mock_workspace.encryption = None + mock_workspace.public_network_access = None + + mock_client = MagicMock() + mock_client.workspaces = MagicMock() + mock_client.workspaces.list_by_resource_group.return_value = [mock_workspace] + + with patch( + "prowler.providers.azure.services.databricks.databricks_service.Databricks._get_workspaces", + return_value={}, + ): + databricks = Databricks(set_mocked_azure_provider()) + + databricks.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + databricks.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = databricks._get_workspaces() + + mock_client.workspaces.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.workspaces.list_by_subscription.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + assert "ws-id-1" in result[AZURE_SUBSCRIPTION_ID] + + def test_get_workspaces_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.workspaces = MagicMock() + + with patch( + "prowler.providers.azure.services.databricks.databricks_service.Databricks._get_workspaces", + return_value={}, + ): + databricks = Databricks(set_mocked_azure_provider()) + + databricks.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + databricks.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = databricks._get_workspaces() + + mock_client.workspaces.list_by_resource_group.assert_not_called() + mock_client.workspaces.list_by_subscription.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + def test_get_workspaces_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.workspaces = MagicMock() + mock_client.workspaces.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.databricks.databricks_service.Databricks._get_workspaces", + return_value={}, + ): + databricks = Databricks(set_mocked_azure_provider()) + + databricks.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + databricks.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = databricks._get_workspaces() + + assert mock_client.workspaces.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_workspaces_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.workspaces = MagicMock() + mock_client.workspaces.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.databricks.databricks_service.Databricks._get_workspaces", + return_value={}, + ): + databricks = Databricks(set_mocked_azure_provider()) + + databricks.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + databricks.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + databricks._get_workspaces() + + mock_client.workspaces.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py b/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py index 75f3d5014a..8a57fa0fcb 100644 --- a/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py +++ b/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py @@ -16,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_additional_email_configured_with_a_security_contact: def test_defender_no_subscriptions(self): defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = {} @@ -40,6 +41,7 @@ class Test_defender_additional_email_configured_with_a_security_contact: def test_defender_no_additional_emails(self): resource_id = str(uuid4()) defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { @@ -87,6 +89,7 @@ class Test_defender_additional_email_configured_with_a_security_contact: def test_defender_additional_email_configured(self): resource_id = str(uuid4()) defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed_test.py b/tests/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed_test.py index 1e567ac153..e9030a2a52 100644 --- a/tests/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed_test.py +++ b/tests/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_assessments_vm_endpoint_protection_installed: def test_defender_no_subscriptions(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = {} @@ -36,6 +37,7 @@ class Test_defender_assessments_vm_endpoint_protection_installed: def test_defender_subscriptions_with_no_assessments(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = {AZURE_SUBSCRIPTION_ID: {}} @@ -59,6 +61,7 @@ class Test_defender_assessments_vm_endpoint_protection_installed: def test_defender_subscriptions_with_healthy_assessments(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} resource_id = str(uuid4()) defender_client.assessments = { @@ -98,6 +101,7 @@ class Test_defender_assessments_vm_endpoint_protection_installed: def test_defender_subscriptions_with_unhealthy_assessments(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} resource_id = str(uuid4()) defender_client.assessments = { diff --git a/tests/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured_test.py b/tests/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured_test.py index ebece2e029..220fbbf4bf 100644 --- a/tests/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured_test.py +++ b/tests/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured_test.py @@ -16,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_attack_path_notifications_properly_configured: def test_no_subscriptions(self): defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = {} defender_client.audit_config = {} @@ -41,6 +42,7 @@ class Test_defender_attack_path_notifications_properly_configured: resource_id = str(uuid4()) contact_name = "default" defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { @@ -89,6 +91,7 @@ class Test_defender_attack_path_notifications_properly_configured: resource_id = str(uuid4()) contact_name = "default" defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { @@ -139,6 +142,7 @@ class Test_defender_attack_path_notifications_properly_configured: resource_id = str(uuid4()) contact_name = "default" defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { @@ -189,6 +193,7 @@ class Test_defender_attack_path_notifications_properly_configured: resource_id = str(uuid4()) contact_name = "default" defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { @@ -237,6 +242,7 @@ class Test_defender_attack_path_notifications_properly_configured: resource_id = str(uuid4()) contact_name = "default" defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { @@ -285,6 +291,7 @@ class Test_defender_attack_path_notifications_properly_configured: resource_id = str(uuid4()) contact_name = "default" defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { @@ -333,6 +340,7 @@ class Test_defender_attack_path_notifications_properly_configured: resource_id = str(uuid4()) contact_name = "default" defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on_test.py b/tests/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on_test.py index 9a99281e94..bfc540f0eb 100644 --- a/tests/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on_test.py +++ b/tests/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on_test.py @@ -15,6 +15,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_auto_provisioning_log_analytics_agent_vms_on: def test_defender_no_app_services(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.auto_provisioning_settings = {} @@ -39,6 +40,7 @@ class Test_defender_auto_provisioning_log_analytics_agent_vms_on: def test_defender_auto_provisioning_log_analytics_off(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.auto_provisioning_settings = { AZURE_SUBSCRIPTION_ID: { @@ -80,6 +82,7 @@ class Test_defender_auto_provisioning_log_analytics_agent_vms_on: def test_defender_auto_provisioning_log_analytics_on(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.auto_provisioning_settings = { AZURE_SUBSCRIPTION_ID: { @@ -121,6 +124,7 @@ class Test_defender_auto_provisioning_log_analytics_agent_vms_on: def test_defender_auto_provisioning_log_analytics_on_and_off(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.auto_provisioning_settings = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on_test.py b/tests/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on_test.py index eeddb61012..b5cf053127 100644 --- a/tests/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on_test.py +++ b/tests/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_auto_provisioning_vulnerabilty_assessments_machines_on: def test_defender_no_app_services(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = {} @@ -37,6 +38,7 @@ class Test_defender_auto_provisioning_vulnerabilty_assessments_machines_on: def test_defender_machines_no_vulnerability_assessment_solution(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { @@ -77,6 +79,7 @@ class Test_defender_auto_provisioning_vulnerabilty_assessments_machines_on: def test_defender_machines_vulnerability_assessment_solution(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities_test.py b/tests/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities_test.py index 510a995692..3eb5ffd4a5 100644 --- a/tests/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities_test.py +++ b/tests/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_container_images_resolved_vulnerabilities: def test_defender_no_subscriptions(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = {} @@ -36,6 +37,7 @@ class Test_defender_container_images_resolved_vulnerabilities: def test_defender_subscription_empty(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = {AZURE_SUBSCRIPTION_ID: {}} @@ -59,6 +61,7 @@ class Test_defender_container_images_resolved_vulnerabilities: def test_defender_subscription_no_assesment(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { @@ -90,6 +93,7 @@ class Test_defender_container_images_resolved_vulnerabilities: def test_defender_subscription_assesment_unhealthy(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { @@ -139,6 +143,7 @@ class Test_defender_container_images_resolved_vulnerabilities: def test_defender_subscription_assesment_healthy(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { @@ -188,6 +193,7 @@ class Test_defender_container_images_resolved_vulnerabilities: def test_defender_subscription_assesment_not_applicable(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled_test.py b/tests/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled_test.py index 977ee8acdb..63e89a844a 100644 --- a/tests/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled_test.py +++ b/tests/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled_test.py @@ -14,6 +14,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_container_images_scan_enabled: def test_defender_no_subscriptions(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} @@ -37,6 +38,7 @@ class Test_defender_container_images_scan_enabled: def test_defender_subscription_empty(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {AZURE_SUBSCRIPTION_ID: {}} @@ -60,6 +62,7 @@ class Test_defender_container_images_scan_enabled: def test_defender_subscription_no_containers(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -92,6 +95,7 @@ class Test_defender_container_images_scan_enabled: def test_defender_subscription_containers_no_extensions(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -137,6 +141,7 @@ class Test_defender_container_images_scan_enabled: def test_defender_subscription_containers_container_images_scan_off(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -182,6 +187,7 @@ class Test_defender_container_images_scan_enabled: def test_defender_subscription_containers_container_images_scan_on(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on_test.py index b2528e28e7..9164b7dfdb 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_app_services_is_on: def test_defender_no_app_services(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} @@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_app_services_is_on: def test_defender_app_services_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -78,6 +80,7 @@ class Test_defender_ensure_defender_for_app_services_is_on: def test_defender_app_services_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on_test.py index 357e3ca9e7..2c83113dc6 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_arm_is_on: def test_defender_no_arm(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} @@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_arm_is_on: def test_defender_arm_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -78,6 +80,7 @@ class Test_defender_ensure_defender_for_arm_is_on: def test_defender_arm_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on_test.py index c10314042b..f4ff5f6471 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_azure_sql_databases_is_on: def test_defender_no_sql_databases(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} @@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_azure_sql_databases_is_on: def test_defender_sql_databases_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -78,6 +80,7 @@ class Test_defender_ensure_defender_for_azure_sql_databases_is_on: def test_defender_sql_databases_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on_test.py index 7ff728add9..02563454a5 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_containers_is_on: def test_defender_no_container_registries(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} @@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_containers_is_on: def test_defender_container_registries_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -78,6 +80,7 @@ class Test_defender_ensure_defender_for_containers_is_on: def test_defender_container_registries_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on_test.py index 351f38d97f..a48b3678fe 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_cosmosdb_is_on: def test_defender_no_cosmosdb(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} @@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_cosmosdb_is_on: def test_defender_cosmosdb_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -78,6 +80,7 @@ class Test_defender_ensure_defender_for_cosmosdb_is_on: def test_defender_cosmosdb_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on_test.py index 48cbc57ad1..8cde319553 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_databases_is_on: def test_defender_no_databases(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} @@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_databases_is_on: def test_defender_databases_sql_servers(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -70,6 +72,7 @@ class Test_defender_ensure_defender_for_databases_is_on: def test_defender_databases_sql_server_virtual_machines(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -103,6 +106,7 @@ class Test_defender_ensure_defender_for_databases_is_on: def test_defender_databases_open_source_relation_databases(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -136,6 +140,7 @@ class Test_defender_ensure_defender_for_databases_is_on: def test_defender_databases_cosmosdbs(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -169,6 +174,7 @@ class Test_defender_ensure_defender_for_databases_is_on: def test_defender_databases_all_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -228,6 +234,7 @@ class Test_defender_ensure_defender_for_databases_is_on: def test_defender_databases_cosmosdb_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on_test.py index 6b50ea4c5f..e41f6499d8 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_dns_is_on: def test_defender_no_dns(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} @@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_dns_is_on: def test_defender_dns_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -78,6 +80,7 @@ class Test_defender_ensure_defender_for_dns_is_on: def test_defender_dns_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on_test.py index f587a92961..e32d7b6b24 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_keyvault_is_on: def test_defender_no_keyvaults(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} @@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_keyvault_is_on: def test_defender_keyvaults_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -78,6 +80,7 @@ class Test_defender_ensure_defender_for_keyvault_is_on: def test_defender_keyvaults_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on_test.py index dc28fb3bb2..7d74712097 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_os_relational_databases_is_on: def test_defender_no_os_relational_databases(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} @@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_os_relational_databases_is_on: def test_defender_os_relational_databases_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -81,6 +83,7 @@ class Test_defender_ensure_defender_for_os_relational_databases_is_on: def test_defender_os_relational_databases_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on_test.py index 226b26ad3a..d8d60d0507 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_server_is_on: def test_defender_no_server(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} @@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_server_is_on: def test_defender_server_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -78,6 +80,7 @@ class Test_defender_ensure_defender_for_server_is_on: def test_defender_server_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on_test.py index 1907cdbb6c..1f63aed4c8 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_sql_servers_is_on: def test_defender_no_server(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} @@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_sql_servers_is_on: def test_defender_server_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -78,6 +80,7 @@ class Test_defender_ensure_defender_for_sql_servers_is_on: def test_defender_server_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on_test.py index f5eee6879a..d534db195f 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_storage_is_on: def test_defender_no_server(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} @@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_storage_is_on: def test_defender_server_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { @@ -78,6 +80,7 @@ class Test_defender_ensure_defender_for_storage_is_on: def test_defender_server_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on_test.py index f4ac17c5ae..54b8f17f9a 100644 --- a/tests/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on_test.py @@ -15,6 +15,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_iot_hub_defender_is_on: def test_defender_no_subscriptions(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.iot_security_solutions = {} @@ -38,6 +39,7 @@ class Test_defender_ensure_iot_hub_defender_is_on: def test_defender_no_iot_hub_solutions(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.iot_security_solutions = {AZURE_SUBSCRIPTION_ID: {}} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} @@ -69,6 +71,7 @@ class Test_defender_ensure_iot_hub_defender_is_on: def test_defender_iot_hub_solution_disabled(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.iot_security_solutions = { AZURE_SUBSCRIPTION_ID: { @@ -106,6 +109,7 @@ class Test_defender_ensure_iot_hub_defender_is_on: def test_defender_iot_hub_solution_enabled(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.iot_security_solutions = { AZURE_SUBSCRIPTION_ID: { @@ -145,6 +149,7 @@ class Test_defender_ensure_iot_hub_defender_is_on: resource_id_enabled = str(uuid4()) resource_id_disabled = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.iot_security_solutions = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled_test.py b/tests/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled_test.py index 7770ab0baf..23abc7beb2 100644 --- a/tests/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_mcas_is_enabled: def test_defender_no_settings(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.settings = {} @@ -37,6 +38,7 @@ class Test_defender_ensure_mcas_is_enabled: def test_defender_mcas_disabled(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.settings = { AZURE_SUBSCRIPTION_ID: { @@ -79,6 +81,7 @@ class Test_defender_ensure_mcas_is_enabled: def test_defender_mcas_enabled(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.settings = { AZURE_SUBSCRIPTION_ID: { @@ -120,6 +123,7 @@ class Test_defender_ensure_mcas_is_enabled: def test_defender_mcas_no_settings(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.settings = {AZURE_SUBSCRIPTION_ID: {}} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} diff --git a/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py b/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py index 8d2a3a05f7..b5c3508016 100644 --- a/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py @@ -16,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_notify_alerts_severity_is_high: def test_defender_no_subscriptions(self): defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = {} @@ -40,6 +41,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: def test_defender_severity_alerts_critical(self): resource_id = str(uuid4()) defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { @@ -87,6 +89,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: def test_defender_severity_alerts_high(self): resource_id = str(uuid4()) defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { @@ -135,6 +138,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: def test_defender_severity_alerts_low(self): resource_id = str(uuid4()) defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { @@ -182,6 +186,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: def test_defender_default_security_contact_not_found(self): defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py b/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py index b125320764..d95712806f 100644 --- a/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py @@ -16,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_notify_emails_to_owners: def test_defender_no_subscriptions(self): defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = {} @@ -40,6 +41,7 @@ class Test_defender_ensure_notify_emails_to_owners: def test_defender_no_notify_emails_to_owners(self): resource_id = str(uuid4()) defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { @@ -80,6 +82,7 @@ class Test_defender_ensure_notify_emails_to_owners: def test_defender_notify_emails_to_owners_off(self): resource_id = str(uuid4()) defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { @@ -127,6 +130,7 @@ class Test_defender_ensure_notify_emails_to_owners: def test_defender_notify_emails_to_owners(self): resource_id = str(uuid4()) defender_client = mock.MagicMock() + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied_test.py b/tests/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied_test.py index e6a80853dd..4d98db1939 100644 --- a/tests/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_system_updates_are_applied: def test_defender_no_app_services(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = {} @@ -37,6 +38,7 @@ class Test_defender_ensure_system_updates_are_applied: def test_defender_machines_no_log_analytics_installed(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { @@ -89,6 +91,7 @@ class Test_defender_ensure_system_updates_are_applied: ): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { @@ -139,6 +142,7 @@ class Test_defender_ensure_system_updates_are_applied: def test_defender_machines_no_system_updates_installed(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { @@ -191,6 +195,7 @@ class Test_defender_ensure_system_updates_are_applied: ): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled_test.py b/tests/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled_test.py index 202e332b3f..2c045b6e49 100644 --- a/tests/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_wdatp_is_enabled: def test_defender_no_settings(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.settings = {} @@ -37,6 +38,7 @@ class Test_defender_ensure_wdatp_is_enabled: def test_defender_wdatp_disabled(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.settings = { AZURE_SUBSCRIPTION_ID: { @@ -79,6 +81,7 @@ class Test_defender_ensure_wdatp_is_enabled: def test_defender_wdatp_enabled(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.settings = { AZURE_SUBSCRIPTION_ID: { @@ -120,6 +123,7 @@ class Test_defender_ensure_wdatp_is_enabled: def test_defender_wdatp_no_settings(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.settings = {AZURE_SUBSCRIPTION_ID: {}} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} diff --git a/tests/providers/azure/services/defender/defender_service_test.py b/tests/providers/azure/services/defender/defender_service_test.py index 4308467263..71457fc6ac 100644 --- a/tests/providers/azure/services/defender/defender_service_test.py +++ b/tests/providers/azure/services/defender/defender_service_test.py @@ -1,5 +1,5 @@ from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.azure.services.defender.defender_service import ( Assesment, @@ -13,6 +13,8 @@ from prowler.providers.azure.services.defender.defender_service import ( ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -358,3 +360,263 @@ class Test_Defender_Service_Assessments_None_Handling: "Assessment Unhealthy" ] assert assessment_unhealthy.status == "Unhealthy" + + +DEFENDER_INIT_PATCHES = [ + "prowler.providers.azure.services.defender.defender_service.Defender._get_pricings", + "prowler.providers.azure.services.defender.defender_service.Defender._get_auto_provisioning_settings", + "prowler.providers.azure.services.defender.defender_service.Defender._get_assessments", + "prowler.providers.azure.services.defender.defender_service.Defender._get_settings", + "prowler.providers.azure.services.defender.defender_service.Defender._get_security_contacts", + "prowler.providers.azure.services.defender.defender_service.Defender._get_iot_security_solutions", + "prowler.providers.azure.services.defender.defender_service.Defender._get_jit_policies", +] + + +class Test_Defender_get_iot_security_solutions: + def test_get_iot_security_solutions_no_resource_groups(self): + mock_client = MagicMock() + mock_client.iot_security_solution.list_by_subscription.return_value = [] + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = None + + result = defender._get_iot_security_solutions() + + mock_client.iot_security_solution.list_by_subscription.assert_called_once() + mock_client.iot_security_solution.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_iot_security_solutions_with_resource_group(self): + mock_client = MagicMock() + mock_client.iot_security_solution.list_by_resource_group.return_value = [] + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = defender._get_iot_security_solutions() + + mock_client.iot_security_solution.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.iot_security_solution.list_by_subscription.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_iot_security_solutions_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = defender._get_iot_security_solutions() + + mock_client.iot_security_solution.list_by_resource_group.assert_not_called() + mock_client.iot_security_solution.list_by_subscription.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + +class Test_Defender_get_jit_policies: + def test_get_jit_policies_no_resource_groups(self): + mock_client = MagicMock() + mock_client.jit_network_access_policies.list.return_value = [] + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = None + + result = defender._get_jit_policies() + + mock_client.jit_network_access_policies.list.assert_called_once() + mock_client.jit_network_access_policies.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_jit_policies_with_resource_group(self): + mock_client = MagicMock() + mock_client.jit_network_access_policies.list_by_resource_group.return_value = [] + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = defender._get_jit_policies() + + mock_client.jit_network_access_policies.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.jit_network_access_policies.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_jit_policies_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = defender._get_jit_policies() + + mock_client.jit_network_access_policies.list_by_resource_group.assert_not_called() + mock_client.jit_network_access_policies.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + def test_get_iot_security_solutions_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.iot_security_solution.list_by_resource_group.return_value = [] + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = defender._get_iot_security_solutions() + + assert mock_client.iot_security_solution.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_iot_security_solutions_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.iot_security_solution.list_by_resource_group.return_value = [] + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + defender._get_iot_security_solutions() + + mock_client.iot_security_solution.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) + + +class Test_Defender_get_jit_policies_extra: + def test_get_jit_policies_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.jit_network_access_policies.list_by_resource_group.return_value = [] + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = defender._get_jit_policies() + + assert ( + mock_client.jit_network_access_policies.list_by_resource_group.call_count + == 2 + ) + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_jit_policies_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.jit_network_access_policies.list_by_resource_group.return_value = [] + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + defender._get_jit_policies() + + mock_client.jit_network_access_policies.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_admin_portals/entra_conditional_access_policy_require_mfa_for_admin_portals_test.py b/tests/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_admin_portals/entra_conditional_access_policy_require_mfa_for_admin_portals_test.py index 3909b80568..aa572cffb6 100644 --- a/tests/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_admin_portals/entra_conditional_access_policy_require_mfa_for_admin_portals_test.py +++ b/tests/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_admin_portals/entra_conditional_access_policy_require_mfa_for_admin_portals_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_conditional_access_policy_require_mfa_for_admin_portals: def test_entra_no_subscriptions(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,7 +30,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_admin_portals: def test_entra_tenant_no_policies(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -61,6 +61,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_admin_portals: def test_entra_tenant_policy_no_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( @@ -105,6 +106,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_admin_portals: def test_entra_tenant_policy_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( @@ -149,6 +151,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_admin_portals: def test_entra_tenant_policy_mfa_disabled(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( @@ -193,6 +196,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_admin_portals: def test_entra_tenant_policy_mfa_no_target(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( @@ -237,6 +241,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_admin_portals: def test_entra_tenant_policy_mfa_no_users(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( diff --git a/tests/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_management_api/entra_conditional_access_policy_require_mfa_for_management_api_test.py b/tests/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_management_api/entra_conditional_access_policy_require_mfa_for_management_api_test.py index 3c880886ee..82362135a9 100644 --- a/tests/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_management_api/entra_conditional_access_policy_require_mfa_for_management_api_test.py +++ b/tests/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_management_api/entra_conditional_access_policy_require_mfa_for_management_api_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_conditional_access_policy_require_mfa_for_management_api: def test_entra_no_subscriptions(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,7 +30,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_management_api: def test_entra_tenant_no_policies(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -61,6 +61,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_management_api: def test_entra_tenant_policy_no_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( @@ -105,6 +106,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_management_api: def test_entra_tenant_policy_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( @@ -149,6 +151,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_management_api: def test_entra_tenant_policy_mfa_disabled(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( @@ -193,6 +196,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_management_api: def test_entra_tenant_policy_mfa_no_target(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( @@ -237,6 +241,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_management_api: def test_entra_tenant_policy_mfa_no_users(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( diff --git a/tests/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users_test.py b/tests/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users_test.py index 4820f13ad9..4270f485f3 100644 --- a/tests/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users_test.py +++ b/tests/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_global_admin_in_less_than_five_users: def test_entra_no_tenants(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -32,7 +32,7 @@ class Test_entra_global_admin_in_less_than_five_users: def test_entra_tenant_empty(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -57,7 +57,7 @@ class Test_entra_global_admin_in_less_than_five_users: def test_entra_less_than_five_global_admins(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -110,7 +110,7 @@ class Test_entra_global_admin_in_less_than_five_users: def test_entra_more_than_five_global_admins(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -178,7 +178,7 @@ class Test_entra_global_admin_in_less_than_five_users: def test_entra_exactly_five_global_admins(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", diff --git a/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py b/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py index 4d2f289a90..04d838a2c0 100644 --- a/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py +++ b/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_non_privileged_user_has_mfa: def test_entra_no_tenants(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,7 +30,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_tenant_no_users(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -53,6 +53,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_user_no_privileged_no_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( @@ -100,6 +101,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_user_no_privileged_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( @@ -144,6 +146,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_disabled_user_no_privileged_no_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( @@ -184,6 +187,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_disabled_user_no_privileged_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( @@ -224,6 +228,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_user_privileged_no_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( @@ -265,6 +270,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_user_privileged_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( diff --git a/tests/providers/azure/services/entra/entra_policy_default_users_cannot_create_security_groups/entra_policy_default_users_cannot_create_security_groups_test.py b/tests/providers/azure/services/entra/entra_policy_default_users_cannot_create_security_groups/entra_policy_default_users_cannot_create_security_groups_test.py index 603fae5863..df614a06e4 100644 --- a/tests/providers/azure/services/entra/entra_policy_default_users_cannot_create_security_groups/entra_policy_default_users_cannot_create_security_groups_test.py +++ b/tests/providers/azure/services/entra/entra_policy_default_users_cannot_create_security_groups/entra_policy_default_users_cannot_create_security_groups_test.py @@ -7,6 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_policy_default_users_cannot_create_security_groups: def test_entra_no_tenants(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} entra_client.authorization_policy = {} with ( @@ -29,6 +30,7 @@ class Test_entra_policy_default_users_cannot_create_security_groups: def test_entra_tenant_empty(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -75,6 +77,7 @@ class Test_entra_policy_default_users_cannot_create_security_groups: self, ): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -124,6 +127,7 @@ class Test_entra_policy_default_users_cannot_create_security_groups: self, ): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( diff --git a/tests/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_apps/entra_policy_ensure_default_user_cannot_create_apps_test.py b/tests/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_apps/entra_policy_ensure_default_user_cannot_create_apps_test.py index d62941388c..5bfa9b2b4b 100644 --- a/tests/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_apps/entra_policy_ensure_default_user_cannot_create_apps_test.py +++ b/tests/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_apps/entra_policy_ensure_default_user_cannot_create_apps_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_policy_ensure_default_user_cannot_create_apps: def test_entra_no_tenants(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,6 +30,7 @@ class Test_entra_policy_ensure_default_user_cannot_create_apps: def test_entra_tenant_empty(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -75,7 +76,7 @@ class Test_entra_policy_ensure_default_user_cannot_create_apps: def test_entra_default_user_role_permissions_not_allowed_to_create_apps(self): id = str(uuid4()) entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -122,7 +123,7 @@ class Test_entra_policy_ensure_default_user_cannot_create_apps: def test_entra_default_user_role_permissions_allowed_to_create_apps(self): id = str(uuid4()) entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", diff --git a/tests/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_tenants/entra_policy_ensure_default_user_cannot_create_tenants_test.py b/tests/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_tenants/entra_policy_ensure_default_user_cannot_create_tenants_test.py index b9a678bc08..391c3f424f 100644 --- a/tests/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_tenants/entra_policy_ensure_default_user_cannot_create_tenants_test.py +++ b/tests/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_tenants/entra_policy_ensure_default_user_cannot_create_tenants_test.py @@ -7,6 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_policy_ensure_default_user_cannot_create_tenants: def test_entra_no_tenants(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} entra_client.authorization_policy = {} with ( @@ -29,6 +30,7 @@ class Test_entra_policy_ensure_default_user_cannot_create_tenants: def test_entra_empty_tenant(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -74,7 +76,7 @@ class Test_entra_policy_ensure_default_user_cannot_create_tenants: def test_entra_default_user_role_permissions_not_allowed_to_create_tenants(self): id = str(uuid4()) entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -121,7 +123,7 @@ class Test_entra_policy_ensure_default_user_cannot_create_tenants: def test_entra_default_user_role_permissions_allowed_to_create_tenants(self): id = str(uuid4()) entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", diff --git a/tests/providers/azure/services/entra/entra_policy_guest_invite_only_for_admin_roles/entra_policy_guest_invite_only_for_admin_roles_test.py b/tests/providers/azure/services/entra/entra_policy_guest_invite_only_for_admin_roles/entra_policy_guest_invite_only_for_admin_roles_test.py index a59c84b6b3..e844b900f1 100644 --- a/tests/providers/azure/services/entra/entra_policy_guest_invite_only_for_admin_roles/entra_policy_guest_invite_only_for_admin_roles_test.py +++ b/tests/providers/azure/services/entra/entra_policy_guest_invite_only_for_admin_roles/entra_policy_guest_invite_only_for_admin_roles_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_policy_guest_invite_only_for_admin_roles: def test_entra_no_tenants(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,6 +30,7 @@ class Test_entra_policy_guest_invite_only_for_admin_roles: def test_entra_empty_tenant(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -76,6 +77,7 @@ class Test_entra_policy_guest_invite_only_for_admin_roles: def test_entra_tenant_policy_allow_invites_from_everyone(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -120,6 +122,7 @@ class Test_entra_policy_guest_invite_only_for_admin_roles: def test_entra_tenant_policy_allow_invites_from_admins(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -164,6 +167,7 @@ class Test_entra_policy_guest_invite_only_for_admin_roles: def test_entra_tenant_policy_allow_invites_from_none(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( diff --git a/tests/providers/azure/services/entra/entra_policy_guest_users_access_restrictions/entra_policy_guest_users_access_restrictions_test.py b/tests/providers/azure/services/entra/entra_policy_guest_users_access_restrictions/entra_policy_guest_users_access_restrictions_test.py index 4f70895846..9b7aecf053 100644 --- a/tests/providers/azure/services/entra/entra_policy_guest_users_access_restrictions/entra_policy_guest_users_access_restrictions_test.py +++ b/tests/providers/azure/services/entra/entra_policy_guest_users_access_restrictions/entra_policy_guest_users_access_restrictions_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_policy_guest_users_access_restrictions: def test_entra_no_tenants(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,6 +30,7 @@ class Test_entra_policy_guest_users_access_restrictions: def test_entra_tenant_empty(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -74,6 +75,7 @@ class Test_entra_policy_guest_users_access_restrictions: def test_entra_tenant_policy_access_same_as_member(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -117,6 +119,7 @@ class Test_entra_policy_guest_users_access_restrictions: def test_entra_tenant_policy_limited_access(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -160,6 +163,7 @@ class Test_entra_policy_guest_users_access_restrictions: def test_entra_tenant_policy_access_restricted(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( diff --git a/tests/providers/azure/services/entra/entra_policy_restricts_user_consent_for_apps/entra_policy_restricts_user_consent_for_apps_test.py b/tests/providers/azure/services/entra/entra_policy_restricts_user_consent_for_apps/entra_policy_restricts_user_consent_for_apps_test.py index 36a03cab1d..bf4c43b2c2 100644 --- a/tests/providers/azure/services/entra/entra_policy_restricts_user_consent_for_apps/entra_policy_restricts_user_consent_for_apps_test.py +++ b/tests/providers/azure/services/entra/entra_policy_restricts_user_consent_for_apps/entra_policy_restricts_user_consent_for_apps_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_policy_restricts_user_consent_for_apps: def test_entra_no_tenants(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,6 +30,7 @@ class Test_entra_policy_restricts_user_consent_for_apps: def test_entra_tenant_empty(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -74,7 +75,7 @@ class Test_entra_policy_restricts_user_consent_for_apps: def test_entra_tenant_no_default_user_role_permissions(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -116,7 +117,7 @@ class Test_entra_policy_restricts_user_consent_for_apps: def test_entra_tenant_no_consent(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -162,7 +163,7 @@ class Test_entra_policy_restricts_user_consent_for_apps: def test_entra_tenant_legacy_consent(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", diff --git a/tests/providers/azure/services/entra/entra_policy_user_consent_for_verified_apps/entra_policy_user_consent_for_verified_apps_test.py b/tests/providers/azure/services/entra/entra_policy_user_consent_for_verified_apps/entra_policy_user_consent_for_verified_apps_test.py index 02bd0a2220..74dc98fd2a 100644 --- a/tests/providers/azure/services/entra/entra_policy_user_consent_for_verified_apps/entra_policy_user_consent_for_verified_apps_test.py +++ b/tests/providers/azure/services/entra/entra_policy_user_consent_for_verified_apps/entra_policy_user_consent_for_verified_apps_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_policy_user_consent_for_verified_apps: def test_entra_no_subscriptions(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,7 +30,7 @@ class Test_entra_policy_user_consent_for_verified_apps: def test_entra_tenant_no_consent(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -76,7 +76,7 @@ class Test_entra_policy_user_consent_for_verified_apps: def test_entra_tenant_legacy_consent(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", diff --git a/tests/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa_test.py b/tests/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa_test.py index 31e0a57bff..3475baf592 100644 --- a/tests/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa_test.py +++ b/tests/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_privileged_user_has_mfa: def test_entra_no_tenants(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,7 +30,7 @@ class Test_entra_privileged_user_has_mfa: def test_entra_tenant_no_users(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -53,6 +53,7 @@ class Test_entra_privileged_user_has_mfa: def test_entra_user_no_privileged_no_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( @@ -92,6 +93,7 @@ class Test_entra_privileged_user_has_mfa: def test_entra_user_no_privileged_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( @@ -131,6 +133,7 @@ class Test_entra_privileged_user_has_mfa: def test_entra_user_privileged_no_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( @@ -177,6 +180,7 @@ class Test_entra_privileged_user_has_mfa: def test_entra_user_privileged_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( diff --git a/tests/providers/azure/services/entra/entra_security_defaults_enabled/entra_security_defaults_enabled_test.py b/tests/providers/azure/services/entra/entra_security_defaults_enabled/entra_security_defaults_enabled_test.py index 562c008c52..11d6d8ff8e 100644 --- a/tests/providers/azure/services/entra/entra_security_defaults_enabled/entra_security_defaults_enabled_test.py +++ b/tests/providers/azure/services/entra/entra_security_defaults_enabled/entra_security_defaults_enabled_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_security_defaults_enabled: def test_entra_no_tenants(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,7 +30,7 @@ class Test_entra_security_defaults_enabled: def test_entra_tenant_empty(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -58,7 +58,7 @@ class Test_entra_security_defaults_enabled: def test_entra_security_default_enabled(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -93,7 +93,7 @@ class Test_entra_security_defaults_enabled: def test_entra_security_default_disabled(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", diff --git a/tests/providers/azure/services/entra/entra_trusted_named_locations_exists/entra_trusted_named_locations_exists_test.py b/tests/providers/azure/services/entra/entra_trusted_named_locations_exists/entra_trusted_named_locations_exists_test.py index 2af5c975cb..89a8ba7f07 100644 --- a/tests/providers/azure/services/entra/entra_trusted_named_locations_exists/entra_trusted_named_locations_exists_test.py +++ b/tests/providers/azure/services/entra/entra_trusted_named_locations_exists/entra_trusted_named_locations_exists_test.py @@ -10,7 +10,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_entra_trusted_named_locations_exists: def test_entra_no_tenants(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -34,7 +34,7 @@ class Test_entra_trusted_named_locations_exists: def test_entra_tenant_empty(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -67,7 +67,7 @@ class Test_entra_trusted_named_locations_exists: def test_entra_named_location_with_ip_ranges(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -111,7 +111,7 @@ class Test_entra_trusted_named_locations_exists: def test_entra_named_location_without_ip_ranges(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -156,7 +156,7 @@ class Test_entra_trusted_named_locations_exists: def test_entra_new_named_location_with_ip_ranges_not_trusted(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", diff --git a/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py b/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py index 46dc9389af..83c06ea5b6 100644 --- a/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py +++ b/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py @@ -14,10 +14,11 @@ from tests.providers.azure.azure_fixtures import ( class Test_iam_assignment_priviledge_access_vm_has_mfa: def test_iam_no_roles(self): iam_client = mock.MagicMock + iam_client.resource_groups = {} iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} entra_client = mock.MagicMock + entra_client.resource_groups = {} entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} - with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -41,9 +42,11 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: def test_entra_user_with_vm_access_has_mfa(self): iam_client = mock.MagicMock + iam_client.resource_groups = {} iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_assigment_id = str(uuid4()) entra_client = mock.MagicMock + entra_client.resource_groups = {} entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} user_id = str(uuid4()) @@ -112,9 +115,11 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: def test_entra_user_with_vm_access_has_mfa_no_mfa(self): iam_client = mock.MagicMock + iam_client.resource_groups = {} iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_assigment_id = str(uuid4()) entra_client = mock.MagicMock + entra_client.resource_groups = {} entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} user_id = str(uuid4()) @@ -183,9 +188,11 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: def test_entra_user_with_vm_access_has_mfa_no_user(self): iam_client = mock.MagicMock + iam_client.resource_groups = {} iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_assigment_id = str(uuid4()) entra_client = mock.MagicMock + entra_client.resource_groups = {} entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} user_id = str(uuid4()) @@ -237,9 +244,11 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: def test_entra_user_with_vm_access_has_mfa_no_role(self): iam_client = mock.MagicMock + iam_client.resource_groups = {} iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_assigment_id = str(uuid4()) entra_client = mock.MagicMock + entra_client.resource_groups = {} entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} user_id = str(uuid4()) diff --git a/tests/providers/azure/services/entra/entra_users_cannot_create_microsoft_365_groups/entra_users_cannot_create_microsoft_365_groups_test.py b/tests/providers/azure/services/entra/entra_users_cannot_create_microsoft_365_groups/entra_users_cannot_create_microsoft_365_groups_test.py index ee82e9a07a..eb7269f6f2 100644 --- a/tests/providers/azure/services/entra/entra_users_cannot_create_microsoft_365_groups/entra_users_cannot_create_microsoft_365_groups_test.py +++ b/tests/providers/azure/services/entra/entra_users_cannot_create_microsoft_365_groups/entra_users_cannot_create_microsoft_365_groups_test.py @@ -11,7 +11,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_entra_users_cannot_create_microsoft_365_groups: def test_entra_no_tenant(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -35,7 +35,7 @@ class Test_entra_users_cannot_create_microsoft_365_groups: def test_entra_tenant_empty(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -65,7 +65,7 @@ class Test_entra_users_cannot_create_microsoft_365_groups: def test_entra_users_cannot_create_microsoft_365_groups(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -114,7 +114,7 @@ class Test_entra_users_cannot_create_microsoft_365_groups: def test_entra_users_can_create_microsoft_365_groups(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -161,7 +161,7 @@ class Test_entra_users_cannot_create_microsoft_365_groups: def test_entra_users_can_create_microsoft_365_groups_no_setting(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", diff --git a/tests/providers/azure/services/iam/azure_iam_service_test.py b/tests/providers/azure/services/iam/azure_iam_service_test.py new file mode 100644 index 0000000000..3f1dfec6fc --- /dev/null +++ b/tests/providers/azure/services/iam/azure_iam_service_test.py @@ -0,0 +1,162 @@ +from unittest.mock import MagicMock, patch + +from prowler.providers.azure.services.iam.iam_service import IAM +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + set_mocked_azure_provider, +) + + +class Test_IAM_get_roles: + def test_get_roles_no_resource_groups(self): + mock_client = MagicMock() + mock_client.role_definitions.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_roles", + return_value=({}, {}), + ), + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_role_assignments", + return_value={}, + ), + ): + iam = IAM(set_mocked_azure_provider()) + + iam.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + iam.resource_groups = None + + builtin, custom = iam._get_roles() + + mock_client.role_definitions.list.assert_called_once() + assert AZURE_SUBSCRIPTION_ID in builtin + assert AZURE_SUBSCRIPTION_ID in custom + + def test_get_roles_with_resource_group(self): + mock_client = MagicMock() + mock_client.role_definitions.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_roles", + return_value=({}, {}), + ), + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_role_assignments", + return_value={}, + ), + ): + iam = IAM(set_mocked_azure_provider()) + + iam.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + iam.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + builtin, custom = iam._get_roles() + + mock_client.role_definitions.list.assert_called_once() + assert AZURE_SUBSCRIPTION_ID in builtin + assert AZURE_SUBSCRIPTION_ID in custom + + def test_get_roles_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.role_definitions.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_roles", + return_value=({}, {}), + ), + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_role_assignments", + return_value={}, + ), + ): + iam = IAM(set_mocked_azure_provider()) + + iam.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + iam.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + builtin, custom = iam._get_roles() + + mock_client.role_definitions.list.assert_called_once() + assert AZURE_SUBSCRIPTION_ID in builtin + assert AZURE_SUBSCRIPTION_ID in custom + + +class Test_IAM_get_role_assignments: + def test_get_role_assignments_no_resource_groups(self): + mock_client = MagicMock() + mock_client.role_assignments = MagicMock() + mock_client.role_assignments.list_for_subscription.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_roles", + return_value=({}, {}), + ), + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_role_assignments", + return_value={}, + ), + ): + iam = IAM(set_mocked_azure_provider()) + + iam.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + iam.resource_groups = None + + result = iam._get_role_assignments() + + mock_client.role_assignments.list_for_subscription.assert_called_once() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_role_assignments_with_resource_group(self): + mock_client = MagicMock() + mock_client.role_assignments = MagicMock() + mock_client.role_assignments.list_for_subscription.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_roles", + return_value=({}, {}), + ), + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_role_assignments", + return_value={}, + ), + ): + iam = IAM(set_mocked_azure_provider()) + + iam.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + iam.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = iam._get_role_assignments() + + mock_client.role_assignments.list_for_subscription.assert_called_once() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_role_assignments_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.role_assignments = MagicMock() + mock_client.role_assignments.list_for_subscription.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_roles", + return_value=({}, {}), + ), + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_role_assignments", + return_value={}, + ), + ): + iam = IAM(set_mocked_azure_provider()) + + iam.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + iam.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = iam._get_role_assignments() + + mock_client.role_assignments.list_for_subscription.assert_called_once() + assert AZURE_SUBSCRIPTION_ID in result diff --git a/tests/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks_test.py b/tests/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks_test.py index 5125130871..2d808c7102 100644 --- a/tests/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks_test.py +++ b/tests/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks_test.py @@ -14,6 +14,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_iam_custom_role_has_permissions_to_administer_resource_locks: def test_iam_no_roles(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.custom_roles = {} @@ -39,6 +40,7 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: self, ): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_name = "test-role" defender_client.custom_roles = { @@ -95,6 +97,7 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: self, ): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_name = "test-role" defender_client.custom_roles = { @@ -144,6 +147,7 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: self, ): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_name = "test-role" role_name2 = "test-role2" @@ -212,6 +216,7 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: def test_iam_custom_roles_empty_list_but_with_key(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.custom_roles = {AZURE_SUBSCRIPTION_ID: {}} diff --git a/tests/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted_test.py b/tests/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted_test.py index 8ccf6e6f64..3ead279d6b 100644 --- a/tests/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted_test.py +++ b/tests/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_iam_role_user_access_admin_restricted: def test_iam_no_role_assignments(self): iam_client = mock.MagicMock + iam_client.resource_groups = {} iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} iam_client.role_assignments = {} iam_client.roles = {} @@ -37,6 +38,7 @@ class Test_iam_role_user_access_admin_restricted: def test_iam_user_access_administrator_role_assigned(self): iam_client = mock.MagicMock + iam_client.resource_groups = {} role_id = str(uuid4()) role_assignment_id = str(uuid4()) agent_id = str(uuid4()) @@ -97,6 +99,7 @@ class Test_iam_role_user_access_admin_restricted: def test_iam_non_user_access_administrator_role_assigned(self): iam_client = mock.MagicMock + iam_client.resource_groups = {} role_id = str(uuid4()) role_assignment_id = str(uuid4()) agent_id = str(uuid4()) diff --git a/tests/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created_test.py b/tests/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created_test.py index 1d2d37ee11..2687cb75a9 100644 --- a/tests/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created_test.py +++ b/tests/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created_test.py @@ -14,6 +14,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_iam_subscription_roles_owner_custom_not_created: def test_iam_no_roles(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.custom_roles = {} @@ -37,6 +38,7 @@ class Test_iam_subscription_roles_owner_custom_not_created: def test_iam_custom_owner_role_created_with_all(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_name = "test-role" defender_client.custom_roles = { @@ -84,6 +86,7 @@ class Test_iam_subscription_roles_owner_custom_not_created: def test_iam_custom_owner_role_created_with_no_permissions(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_name = "test-role" defender_client.custom_roles = { diff --git a/tests/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled_test.py b/tests/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled_test.py index 2fff845a7d..6050263af3 100644 --- a/tests/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled_test.py @@ -244,6 +244,158 @@ class Test_keyvault_logging_enabled: assert result[0].resource_name == "name_keyvault" assert result[0].resource_id == "id" + def test_diagnostic_setting_with_audit_event_category_logging(self): + keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=mock.MagicMock(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_logging_enabled.keyvault_logging_enabled.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_logging_enabled.keyvault_logging_enabled import ( + keyvault_logging_enabled, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVaultInfo, + ) + from prowler.providers.azure.services.monitor.monitor_service import ( + DiagnosticSetting, + ) + + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id="id", + name="name_keyvault", + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[], + secrets=[], + monitor_diagnostic_settings=[ + DiagnosticSetting( + id="id/ds1", + logs=[ + mock.MagicMock( + category_group=None, + category="AuditEvent", + enabled=True, + ), + mock.MagicMock( + category_group=None, + category="AzurePolicyEvaluationDetails", + enabled=False, + ), + ], + storage_account_name="sa1", + storage_account_id="sa_id1", + name="ds_audit_event", + ), + ], + ), + ] + } + check = keyvault_logging_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Key Vault name_keyvault in subscription {AZURE_SUBSCRIPTION_DISPLAY} has a diagnostic setting with audit logging." + ) + assert result[0].resource_name == "name_keyvault" + assert result[0].resource_id == "id" + + def test_diagnostic_setting_with_audit_event_category_disabled(self): + keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=mock.MagicMock(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_logging_enabled.keyvault_logging_enabled.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_logging_enabled.keyvault_logging_enabled import ( + keyvault_logging_enabled, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVaultInfo, + ) + from prowler.providers.azure.services.monitor.monitor_service import ( + DiagnosticSetting, + ) + + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id="id", + name="name_keyvault", + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[], + secrets=[], + monitor_diagnostic_settings=[ + DiagnosticSetting( + id="id/ds1", + logs=[ + mock.MagicMock( + category_group=None, + category="AuditEvent", + enabled=False, + ), + mock.MagicMock( + category_group=None, + category="AzurePolicyEvaluationDetails", + enabled=False, + ), + ], + storage_account_name="sa1", + storage_account_id="sa_id1", + name="ds_audit_event_disabled", + ), + ], + ), + ] + } + check = keyvault_logging_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key Vault name_keyvault in subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have a diagnostic setting with audit logging." + ) + assert result[0].resource_name == "name_keyvault" + assert result[0].resource_id == "id" + def test_multiple_diagnostic_settings_one_compliant(self): keyvault_client = mock.MagicMock keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} diff --git a/tests/providers/azure/services/keyvault/keyvault_service_test.py b/tests/providers/azure/services/keyvault/keyvault_service_test.py index f0a73d9081..e43b7a9fff 100644 --- a/tests/providers/azure/services/keyvault/keyvault_service_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_service_test.py @@ -3,6 +3,8 @@ from unittest.mock import MagicMock, patch from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -263,3 +265,208 @@ class Test_keyvault_service: .storage_account_name == "storage_account_name" ) + + +class Test_KeyVault_get_key_vaults: + def test_get_key_vaults_no_resource_groups(self): + mock_client = MagicMock() + mock_client.vaults = MagicMock() + mock_client.vaults.list_by_subscription.return_value = [] + + mock_provider = MagicMock() + mock_provider.identity = MagicMock() + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + patch( + "prowler.providers.azure.services.keyvault.keyvault_service.KeyVault._get_key_vaults", + return_value={}, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVault, + ) + + keyvault = KeyVault(set_mocked_azure_provider()) + + keyvault.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + keyvault.resource_groups = None + + provider = set_mocked_azure_provider() + with patch( + "prowler.providers.azure.services.keyvault.keyvault_service.monitor_client" + ): + result = keyvault._get_key_vaults(provider) + + mock_client.vaults.list_by_subscription.assert_called_once() + mock_client.vaults.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_key_vaults_with_resource_group(self): + mock_client = MagicMock() + mock_client.vaults = MagicMock() + mock_client.vaults.list_by_resource_group.return_value = [] + + mock_provider = MagicMock() + mock_provider.identity = MagicMock() + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + patch( + "prowler.providers.azure.services.keyvault.keyvault_service.KeyVault._get_key_vaults", + return_value={}, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVault, + ) + + keyvault = KeyVault(set_mocked_azure_provider()) + + keyvault.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + keyvault.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + provider = set_mocked_azure_provider() + with patch( + "prowler.providers.azure.services.keyvault.keyvault_service.monitor_client" + ): + result = keyvault._get_key_vaults(provider) + + mock_client.vaults.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.vaults.list_by_subscription.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_key_vaults_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.vaults = MagicMock() + + mock_provider = MagicMock() + mock_provider.identity = MagicMock() + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + patch( + "prowler.providers.azure.services.keyvault.keyvault_service.KeyVault._get_key_vaults", + return_value={}, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVault, + ) + + keyvault = KeyVault(set_mocked_azure_provider()) + + keyvault.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + keyvault.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + provider = set_mocked_azure_provider() + with patch( + "prowler.providers.azure.services.keyvault.keyvault_service.monitor_client" + ): + result = keyvault._get_key_vaults(provider) + + mock_client.vaults.list_by_resource_group.assert_not_called() + mock_client.vaults.list_by_subscription.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == [] + + def test_get_key_vaults_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.vaults = MagicMock() + mock_client.vaults.list_by_resource_group.return_value = [] + + mock_provider = MagicMock() + mock_provider.identity = MagicMock() + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + patch( + "prowler.providers.azure.services.keyvault.keyvault_service.KeyVault._get_key_vaults", + return_value={}, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVault, + ) + + keyvault = KeyVault(set_mocked_azure_provider()) + + keyvault.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + keyvault.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + provider = set_mocked_azure_provider() + with patch( + "prowler.providers.azure.services.keyvault.keyvault_service.monitor_client" + ): + result = keyvault._get_key_vaults(provider) + + assert mock_client.vaults.list_by_resource_group.call_count == len( + RESOURCE_GROUP_LIST + ) + mock_client.vaults.list_by_subscription.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_key_vaults_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.vaults = MagicMock() + mock_client.vaults.list_by_resource_group.return_value = [] + + mock_provider = MagicMock() + mock_provider.identity = MagicMock() + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + patch( + "prowler.providers.azure.services.keyvault.keyvault_service.KeyVault._get_key_vaults", + return_value={}, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVault, + ) + + keyvault = KeyVault(set_mocked_azure_provider()) + + keyvault.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + keyvault.resource_groups = {AZURE_SUBSCRIPTION_ID: ["MyRG"]} + + provider = set_mocked_azure_provider() + with patch( + "prowler.providers.azure.services.keyvault.keyvault_service.monitor_client" + ): + keyvault._get_key_vaults(provider) + + mock_client.vaults.list_by_resource_group.assert_called_once_with( + resource_group_name="MyRG" + ) diff --git a/tests/providers/azure/services/mysql/mysql_service_test.py b/tests/providers/azure/services/mysql/mysql_service_test.py index 24364f175a..728e50610b 100644 --- a/tests/providers/azure/services/mysql/mysql_service_test.py +++ b/tests/providers/azure/services/mysql/mysql_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.azure.services.mysql.mysql_service import ( Configuration, @@ -7,6 +7,8 @@ from prowler.providers.azure.services.mysql.mysql_service import ( ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -117,3 +119,131 @@ class Test_MySQL_Service: assert configurations["test"].resource_id == "/subscriptions/resource_id" assert configurations["test"].description == "description" assert configurations["test"].value == "value" + + +class Test_MySQL_get_flexible_servers: + def test_get_flexible_servers_no_resource_groups(self): + mock_client = MagicMock() + mock_client.servers.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_flexible_servers", + return_value={}, + ), + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_configurations", + return_value={}, + ), + ): + mysql = MySQL(set_mocked_azure_provider()) + + mysql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + mysql.resource_groups = None + + result = mysql._get_flexible_servers() + + mock_client.servers.list.assert_called_once() + mock_client.servers.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_flexible_servers_with_resource_group(self): + mock_client = MagicMock() + mock_client.servers.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_flexible_servers", + return_value={}, + ), + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_configurations", + return_value={}, + ), + ): + mysql = MySQL(set_mocked_azure_provider()) + + mysql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + mysql.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = mysql._get_flexible_servers() + + mock_client.servers.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.servers.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_flexible_servers_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + + with ( + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_flexible_servers", + return_value={}, + ), + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_configurations", + return_value={}, + ), + ): + mysql = MySQL(set_mocked_azure_provider()) + + mysql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + mysql.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = mysql._get_flexible_servers() + + mock_client.servers.list_by_resource_group.assert_not_called() + mock_client.servers.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + assert result[AZURE_SUBSCRIPTION_ID] == {} + + def test_get_flexible_servers_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.servers.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_flexible_servers", + return_value={}, + ), + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_configurations", + return_value={}, + ), + ): + mysql = MySQL(set_mocked_azure_provider()) + + mysql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + mysql.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = mysql._get_flexible_servers() + + assert mock_client.servers.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_flexible_servers_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.servers.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_flexible_servers", + return_value={}, + ), + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_configurations", + return_value={}, + ), + ): + mysql = MySQL(set_mocked_azure_provider()) + + mysql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + mysql.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + mysql._get_flexible_servers() + + mock_client.servers.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/azure/services/network/network_service_test.py b/tests/providers/azure/services/network/network_service_test.py index 8a0e72542f..9a440b90cc 100644 --- a/tests/providers/azure/services/network/network_service_test.py +++ b/tests/providers/azure/services/network/network_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from azure.mgmt.network.models import FlowLog @@ -8,9 +8,12 @@ from prowler.providers.azure.services.network.network_service import ( NetworkWatcher, PublicIp, SecurityGroup, + VirtualNetwork, ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -66,6 +69,20 @@ def mock_network_get_public_ip_addresses(_): } +def mock_network_get_virtual_networks(_): + return { + AZURE_SUBSCRIPTION_ID: [ + VirtualNetwork( + id="id", + name="name", + location="location", + enable_ddos_protection=False, + subnets=[], + ) + ] + } + + @patch( "prowler.providers.azure.services.network.network_service.Network._get_security_groups", new=mock_network_get_security_groups, @@ -82,6 +99,10 @@ def mock_network_get_public_ip_addresses(_): "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", new=mock_network_get_public_ip_addresses, ) +@patch( + "prowler.providers.azure.services.network.network_service.Network._get_virtual_networks", + new=mock_network_get_virtual_networks, +) class Test_Network_Service: def test_get_client(self): network = Network(set_mocked_azure_provider()) @@ -162,3 +183,905 @@ class Test_Network_Service: network.public_ip_addresses[AZURE_SUBSCRIPTION_ID][0].ip_address == "ip_address" ) + + +class Test_Network_get_security_groups: + def test_get_security_groups_no_resource_groups(self): + mock_client = MagicMock() + mock_client.network_security_groups.list_all.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = None + + result = network._get_security_groups() + + mock_client.network_security_groups.list_all.assert_called_once() + mock_client.network_security_groups.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_security_groups_with_resource_group(self): + mock_client = MagicMock() + mock_client.network_security_groups.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = network._get_security_groups() + + mock_client.network_security_groups.list.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.network_security_groups.list_all.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_security_groups_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = network._get_security_groups() + + mock_client.network_security_groups.list.assert_not_called() + mock_client.network_security_groups.list_all.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == [] + + +class Test_Network_get_network_watchers: + def test_get_network_watchers_no_resource_groups(self): + mock_client = MagicMock() + mock_client.network_watchers = MagicMock() + mock_client.network_watchers.list_all.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = None + + result = network._get_network_watchers() + + mock_client.network_watchers.list_all.assert_called_once() + mock_client.network_watchers.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_network_watchers_with_resource_group(self): + mock_client = MagicMock() + mock_client.network_watchers = MagicMock() + mock_client.network_watchers.list_all.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = network._get_network_watchers() + + mock_client.network_watchers.list_all.assert_called_once() + mock_client.network_watchers.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_network_watchers_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.network_watchers = MagicMock() + mock_client.network_watchers.list_all.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = network._get_network_watchers() + + mock_client.network_watchers.list_all.assert_called_once() + mock_client.network_watchers.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + +class Test_Network_get_bastion_hosts: + def test_get_bastion_hosts_no_resource_groups(self): + mock_client = MagicMock() + mock_client.bastion_hosts = MagicMock() + mock_client.bastion_hosts.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = None + + result = network._get_bastion_hosts() + + mock_client.bastion_hosts.list.assert_called_once() + mock_client.bastion_hosts.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_bastion_hosts_with_resource_group(self): + mock_client = MagicMock() + mock_client.bastion_hosts = MagicMock() + mock_client.bastion_hosts.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = network._get_bastion_hosts() + + mock_client.bastion_hosts.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.bastion_hosts.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_bastion_hosts_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.bastion_hosts = MagicMock() + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = network._get_bastion_hosts() + + mock_client.bastion_hosts.list_by_resource_group.assert_not_called() + mock_client.bastion_hosts.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == [] + + +class Test_Network_get_public_ip_addresses: + def test_get_public_ip_addresses_no_resource_groups(self): + mock_client = MagicMock() + mock_client.public_ip_addresses = MagicMock() + mock_client.public_ip_addresses.list_all.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = None + + result = network._get_public_ip_addresses() + + mock_client.public_ip_addresses.list_all.assert_called_once() + mock_client.public_ip_addresses.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_public_ip_addresses_with_resource_group(self): + mock_client = MagicMock() + mock_client.public_ip_addresses = MagicMock() + mock_client.public_ip_addresses.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = network._get_public_ip_addresses() + + mock_client.public_ip_addresses.list.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.public_ip_addresses.list_all.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_public_ip_addresses_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.public_ip_addresses = MagicMock() + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = network._get_public_ip_addresses() + + mock_client.public_ip_addresses.list.assert_not_called() + mock_client.public_ip_addresses.list_all.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == [] + + def test_get_security_groups_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.network_security_groups = MagicMock() + mock_client.network_security_groups.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = network._get_security_groups() + + assert mock_client.network_security_groups.list.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_security_groups_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.network_security_groups = MagicMock() + mock_client.network_security_groups.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + network._get_security_groups() + + mock_client.network_security_groups.list.assert_called_once_with( + resource_group_name="RG" + ) + + +class Test_Network_get_network_watchers_extra: + def test_get_network_watchers_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.network_watchers = MagicMock() + mock_client.network_watchers.list_all.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = network._get_network_watchers() + + mock_client.network_watchers.list_all.assert_called_once() + mock_client.network_watchers.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_network_watchers_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.network_watchers = MagicMock() + mock_client.network_watchers.list_all.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + network._get_network_watchers() + + mock_client.network_watchers.list_all.assert_called_once() + mock_client.network_watchers.list.assert_not_called() + + +class Test_Network_get_bastion_hosts_extra: + def test_get_bastion_hosts_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.bastion_hosts = MagicMock() + mock_client.bastion_hosts.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = network._get_bastion_hosts() + + assert mock_client.bastion_hosts.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_bastion_hosts_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.bastion_hosts = MagicMock() + mock_client.bastion_hosts.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + network._get_bastion_hosts() + + mock_client.bastion_hosts.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) + + +class Test_Network_get_public_ip_addresses_extra: + def test_get_public_ip_addresses_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.public_ip_addresses = MagicMock() + mock_client.public_ip_addresses.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = network._get_public_ip_addresses() + + assert mock_client.public_ip_addresses.list.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_public_ip_addresses_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.public_ip_addresses = MagicMock() + mock_client.public_ip_addresses.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + network._get_public_ip_addresses() + + mock_client.public_ip_addresses.list.assert_called_once_with( + resource_group_name="RG" + ) + + +class Test_Network_get_virtual_networks_extra: + def _ctx(self): + return ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ) + + def test_get_virtual_networks_no_resource_groups(self): + mock_client = MagicMock() + mock_client.virtual_networks = MagicMock() + mock_client.virtual_networks.list_all.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_virtual_networks", + new=mock_network_get_virtual_networks, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = None + + result = network._get_virtual_networks() + + mock_client.virtual_networks.list_all.assert_called_once() + mock_client.virtual_networks.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_virtual_networks_with_resource_group(self): + mock_client = MagicMock() + mock_client.virtual_networks = MagicMock() + mock_client.virtual_networks.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_virtual_networks", + new=mock_network_get_virtual_networks, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = network._get_virtual_networks() + + mock_client.virtual_networks.list.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.virtual_networks.list_all.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_virtual_networks_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.virtual_networks = MagicMock() + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_virtual_networks", + new=mock_network_get_virtual_networks, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = network._get_virtual_networks() + + mock_client.virtual_networks.list.assert_not_called() + mock_client.virtual_networks.list_all.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == [] + + def test_get_virtual_networks_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.virtual_networks = MagicMock() + mock_client.virtual_networks.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_virtual_networks", + new=mock_network_get_virtual_networks, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = network._get_virtual_networks() + + assert mock_client.virtual_networks.list.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_virtual_networks_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.virtual_networks = MagicMock() + mock_client.virtual_networks.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_virtual_networks", + new=mock_network_get_virtual_networks, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + network._get_virtual_networks() + + mock_client.virtual_networks.list.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/azure/services/policy/policy_service_test.py b/tests/providers/azure/services/policy/policy_service_test.py index 381ab82466..5a983d8610 100644 --- a/tests/providers/azure/services/policy/policy_service_test.py +++ b/tests/providers/azure/services/policy/policy_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.azure.services.policy.policy_service import ( Policy, @@ -6,6 +6,8 @@ from prowler.providers.azure.services.policy.policy_service import ( ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -52,3 +54,99 @@ class Test_Policy_Service: policy.policy_assigments[AZURE_SUBSCRIPTION_ID]["policy-1"].enforcement_mode == "Default" ) + + +class Test_Policy_get_policy_assigments: + def test_get_policy_assigments_no_resource_groups(self): + mock_client = MagicMock() + mock_client.policy_assignments.list.return_value = [] + + with patch( + "prowler.providers.azure.services.policy.policy_service.Policy._get_policy_assigments", + return_value={}, + ): + policy = Policy(set_mocked_azure_provider()) + + policy.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + policy.resource_groups = None + + result = policy._get_policy_assigments() + + mock_client.policy_assignments.list.assert_called_once() + mock_client.policy_assignments.list_for_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_policy_assigments_with_resource_group(self): + mock_client = MagicMock() + mock_client.policy_assignments.list.return_value = [] + + with patch( + "prowler.providers.azure.services.policy.policy_service.Policy._get_policy_assigments", + return_value={}, + ): + policy = Policy(set_mocked_azure_provider()) + + policy.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + policy.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = policy._get_policy_assigments() + + mock_client.policy_assignments.list.assert_called_once() + mock_client.policy_assignments.list_for_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_policy_assigments_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.policy_assignments.list.return_value = [] + + with patch( + "prowler.providers.azure.services.policy.policy_service.Policy._get_policy_assigments", + return_value={}, + ): + policy = Policy(set_mocked_azure_provider()) + + policy.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + policy.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = policy._get_policy_assigments() + + mock_client.policy_assignments.list.assert_called_once() + mock_client.policy_assignments.list_for_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_policy_assigments_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.policy_assignments.list.return_value = [] + + with patch( + "prowler.providers.azure.services.policy.policy_service.Policy._get_policy_assigments", + return_value={}, + ): + policy = Policy(set_mocked_azure_provider()) + + policy.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + policy.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = policy._get_policy_assigments() + + mock_client.policy_assignments.list.assert_called_once() + mock_client.policy_assignments.list_for_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_policy_assigments_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.policy_assignments.list.return_value = [] + + with patch( + "prowler.providers.azure.services.policy.policy_service.Policy._get_policy_assigments", + return_value={}, + ): + policy = Policy(set_mocked_azure_provider()) + + policy.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + policy.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + policy._get_policy_assigments() + + mock_client.policy_assignments.list.assert_called_once() + mock_client.policy_assignments.list_for_resource_group.assert_not_called() diff --git a/tests/providers/azure/services/postgresql/postgresql_service_test.py b/tests/providers/azure/services/postgresql/postgresql_service_test.py index c0b8bca6c4..c9fea2b307 100644 --- a/tests/providers/azure/services/postgresql/postgresql_service_test.py +++ b/tests/providers/azure/services/postgresql/postgresql_service_test.py @@ -1,5 +1,8 @@ from unittest.mock import MagicMock, patch +import pytest +from azure.core.exceptions import HttpResponseError, ResourceNotFoundError + from prowler.providers.azure.services.postgresql.postgresql_service import ( EntraIdAdmin, Firewall, @@ -8,6 +11,8 @@ from prowler.providers.azure.services.postgresql.postgresql_service import ( ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -116,12 +121,13 @@ class Test_SqlServer_Service: ) def test_get_connection_throttling_missing_parameter_returns_none(self): - # PostgreSQL v18 removed the "connection_throttle.enable" parameter; the - # service must degrade gracefully (quiet None) instead of raising and + # PostgreSQL v18 removed the "connection_throttle.enable" parameter; when + # it is genuinely absent the Azure SDK raises ResourceNotFoundError, and + # the service treats that as "not enabled" (quiet None) instead of # aborting the whole subscription's server inventory. postgresql = PostgreSQL(set_mocked_azure_provider()) mock_client = MagicMock() - mock_client.configurations.get.side_effect = Exception( + mock_client.configurations.get.side_effect = ResourceNotFoundError( "The configuration 'connection_throttle.enable' does not exist for " "server version 18." ) @@ -135,23 +141,22 @@ class Test_SqlServer_Service: assert result is None mock_logger.error.assert_not_called() - def test_get_connection_throttling_unexpected_error_logs_error(self): + def test_get_connection_throttling_unexpected_error_propagates(self): # Any other failure (permissions, throttling, transient API errors) must - # still be logged as an error, while keeping the scan resilient (None). + # NOT be swallowed into None: that would make the downstream check report + # the server as having throttling disabled, hiding a collection failure + # as a security finding. The error propagates so the per-server handler + # in _get_flexible_servers can record it as a collection failure. postgresql = PostgreSQL(set_mocked_azure_provider()) mock_client = MagicMock() - mock_client.configurations.get.side_effect = Exception( - "Some unexpected failure" + mock_client.configurations.get.side_effect = HttpResponseError( + "(AuthorizationFailed) permission denied" ) postgresql.clients[AZURE_SUBSCRIPTION_ID] = mock_client - with patch( - "prowler.providers.azure.services.postgresql.postgresql_service.logger" - ) as mock_logger: - result = postgresql._get_connection_throttling( + with pytest.raises(HttpResponseError): + postgresql._get_connection_throttling( AZURE_SUBSCRIPTION_ID, "resource_group", "server_name" ) - assert result is None - mock_logger.error.assert_called_once() def test_get_log_retention_days(self): postgesql = PostgreSQL(set_mocked_azure_provider()) @@ -238,3 +243,223 @@ class Test_SqlServer_Service: postgesql.flexible_servers[AZURE_SUBSCRIPTION_ID][0].firewall[0].end_ip == "end_ip" ) + + +class Test_PostgreSQL_get_flexible_servers: + def test_get_flexible_servers_no_resource_groups(self): + mock_client = MagicMock() + mock_client.servers.list.return_value = [] + + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.PostgreSQL._get_flexible_servers", + return_value={}, + ): + postgresql = PostgreSQL(set_mocked_azure_provider()) + + postgresql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + postgresql.resource_groups = None + + result = postgresql._get_flexible_servers() + + mock_client.servers.list.assert_called_once() + mock_client.servers.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_flexible_servers_with_resource_group(self): + mock_client = MagicMock() + mock_client.servers.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.PostgreSQL._get_flexible_servers", + return_value={}, + ): + postgresql = PostgreSQL(set_mocked_azure_provider()) + + postgresql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + postgresql.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = postgresql._get_flexible_servers() + + mock_client.servers.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.servers.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_flexible_servers_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.PostgreSQL._get_flexible_servers", + return_value={}, + ): + postgresql = PostgreSQL(set_mocked_azure_provider()) + + postgresql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + postgresql.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = postgresql._get_flexible_servers() + + mock_client.servers.list_by_resource_group.assert_not_called() + mock_client.servers.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == [] + + def test_get_flexible_servers_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.servers.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.PostgreSQL._get_flexible_servers", + return_value={}, + ): + postgresql = PostgreSQL(set_mocked_azure_provider()) + + postgresql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + postgresql.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = postgresql._get_flexible_servers() + + assert mock_client.servers.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_flexible_servers_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.servers.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.PostgreSQL._get_flexible_servers", + return_value={}, + ): + postgresql = PostgreSQL(set_mocked_azure_provider()) + + postgresql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + postgresql.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + postgresql._get_flexible_servers() + + mock_client.servers.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) + + +def _make_server(name): + server = MagicMock() + server.id = ( + f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg/providers/" + f"Microsoft.DBforPostgreSQL/flexibleServers/{name}" + ) + server.name = name + return server + + +class Test_PostgreSQL_Service_Resilience: + """Collecting one flexible server must never abort collection of the rest of + the subscription (regression: a missing/failing per-server configuration + lookup silently dropped every remaining server).""" + + def _build_service_with_client(self, mock_client): + # Skip the real network call during construction, then run the real + # collection against the mocked management client. + with patch.object(PostgreSQL, "_get_flexible_servers", return_value={}): + postgresql = PostgreSQL(set_mocked_azure_provider()) + postgresql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + return postgresql + + def test_missing_connection_throttle_config_still_collects_server(self): + # The "connection_throttle.enable" parameter was removed in PostgreSQL + # 16+, so the lookup raises ConfigurationNotExists on newer servers. + dev = _make_server("dev") + prd = _make_server("prd") + + mock_client = MagicMock() + mock_client.servers.list.return_value = [dev, prd] + server_details = MagicMock() + server_details.location = "westeurope" + mock_client.servers.get.return_value = server_details + mock_client.administrators.list_by_server.return_value = [] + mock_client.firewall_rules.list_by_server.return_value = [] + + def configurations_get(resource_group, server_name, key): + if key == "connection_throttle.enable" and server_name == "prd": + # Azure raises ResourceNotFoundError (ConfigurationNotExists) + # when the parameter does not exist on the server. + raise ResourceNotFoundError( + "(ConfigurationNotExists) The configuration " + "'connection_throttle.enable' does not exist for prd server " + "version 18." + ) + return MagicMock(value="ON") + + mock_client.configurations.get.side_effect = configurations_get + + postgresql = self._build_service_with_client(mock_client) + servers = postgresql._get_flexible_servers() + + names = sorted(server.name for server in servers[AZURE_SUBSCRIPTION_ID]) + assert names == ["dev", "prd"] + prd_server = next(s for s in servers[AZURE_SUBSCRIPTION_ID] if s.name == "prd") + assert prd_server.connection_throttling is None + dev_server = next(s for s in servers[AZURE_SUBSCRIPTION_ID] if s.name == "dev") + assert dev_server.connection_throttling == "ON" + + def test_unexpected_throttling_error_is_not_silently_collected(self): + # An unexpected failure reading "connection_throttle.enable" (e.g. a + # permission, throttling, or transient SDK error) must NOT be turned + # into connection_throttling=None: that would make the downstream check + # report the server as having throttling disabled, hiding a collection + # failure as a security finding. Only ResourceNotFoundError (the + # parameter genuinely missing) is treated as "not enabled"; anything + # else isolates to that server, which is dropped rather than fabricated. + ok = _make_server("ok") + denied = _make_server("denied") + + mock_client = MagicMock() + mock_client.servers.list.return_value = [ok, denied] + server_details = MagicMock() + server_details.location = "westeurope" + mock_client.servers.get.return_value = server_details + mock_client.administrators.list_by_server.return_value = [] + mock_client.firewall_rules.list_by_server.return_value = [] + + def configurations_get(resource_group, server_name, key): + if key == "connection_throttle.enable" and server_name == "denied": + raise HttpResponseError("(AuthorizationFailed) permission denied") + return MagicMock(value="ON") + + mock_client.configurations.get.side_effect = configurations_get + + postgresql = self._build_service_with_client(mock_client) + servers = postgresql._get_flexible_servers() + + collected = servers[AZURE_SUBSCRIPTION_ID] + # The server whose throttling lookup failed unexpectedly is dropped, + # not collected with a fabricated connection_throttling=None. + assert [server.name for server in collected] == ["ok"] + assert all(server.connection_throttling is not None for server in collected) + + def test_one_server_hard_failure_does_not_drop_others(self): + # A failure unrelated to a guarded getter (here, fetching the server + # details) must isolate to that server, not the whole subscription. + ok = _make_server("ok") + broken = _make_server("broken") + + mock_client = MagicMock() + mock_client.servers.list.return_value = [broken, ok] + mock_client.administrators.list_by_server.return_value = [] + mock_client.firewall_rules.list_by_server.return_value = [] + mock_client.configurations.get.return_value = MagicMock(value="ON") + + def servers_get(resource_group, server_name): + if server_name == "broken": + raise Exception("boom: transient failure fetching server details") + details = MagicMock() + details.location = "westeurope" + return details + + mock_client.servers.get.side_effect = servers_get + + postgresql = self._build_service_with_client(mock_client) + servers = postgresql._get_flexible_servers() + + names = [server.name for server in servers[AZURE_SUBSCRIPTION_ID]] + assert names == ["ok"] diff --git a/tests/providers/azure/services/recovery/__init__.py b/tests/providers/azure/services/recovery/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/azure/services/recovery/recovery_service_test.py b/tests/providers/azure/services/recovery/recovery_service_test.py index 93dcad1e38..96c358b7d2 100644 --- a/tests/providers/azure/services/recovery/recovery_service_test.py +++ b/tests/providers/azure/services/recovery/recovery_service_test.py @@ -1,11 +1,18 @@ from types import SimpleNamespace from unittest import mock +from unittest.mock import MagicMock, patch from prowler.providers.azure.services.recovery.recovery_service import ( BackupVault, + Recovery, RecoveryBackup, ) -from tests.providers.azure.azure_fixtures import AZURE_SUBSCRIPTION_ID +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, + set_mocked_azure_provider, +) VAULT_ID = ( f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/" @@ -20,6 +27,139 @@ class BackupClientFake: self.backup_policies.list.return_value = policies +class Test_Recovery_get_vaults: + def test_get_vaults_no_resource_groups(self): + mock_client = MagicMock() + mock_client.vaults = MagicMock() + mock_client.vaults.list_by_subscription_id.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.recovery.recovery_service.Recovery._get_vaults", + return_value={}, + ), + patch( + "prowler.providers.azure.services.recovery.recovery_service.RecoveryBackup", + ), + ): + recovery = Recovery(set_mocked_azure_provider()) + + recovery.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + recovery.resource_groups = None + + result = recovery._get_vaults() + + mock_client.vaults.list_by_subscription_id.assert_called_once() + mock_client.vaults.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_vaults_with_resource_group(self): + mock_vault = MagicMock() + mock_vault.id = "vault-id-1" + mock_vault.name = "my-vault" + mock_vault.location = "eastus" + + mock_client = MagicMock() + mock_client.vaults = MagicMock() + mock_client.vaults.list_by_resource_group.return_value = [mock_vault] + + with ( + patch( + "prowler.providers.azure.services.recovery.recovery_service.Recovery._get_vaults", + return_value={}, + ), + patch( + "prowler.providers.azure.services.recovery.recovery_service.RecoveryBackup", + ), + ): + recovery = Recovery(set_mocked_azure_provider()) + + recovery.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + recovery.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = recovery._get_vaults() + + mock_client.vaults.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.vaults.list_by_subscription_id.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + assert "vault-id-1" in result[AZURE_SUBSCRIPTION_ID] + + def test_get_vaults_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.vaults = MagicMock() + + with ( + patch( + "prowler.providers.azure.services.recovery.recovery_service.Recovery._get_vaults", + return_value={}, + ), + patch( + "prowler.providers.azure.services.recovery.recovery_service.RecoveryBackup", + ), + ): + recovery = Recovery(set_mocked_azure_provider()) + + recovery.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + recovery.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = recovery._get_vaults() + + mock_client.vaults.list_by_resource_group.assert_not_called() + mock_client.vaults.list_by_subscription_id.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + def test_get_vaults_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.vaults = MagicMock() + mock_client.vaults.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.recovery.recovery_service.Recovery._get_vaults", + return_value={}, + ), + patch( + "prowler.providers.azure.services.recovery.recovery_service.RecoveryBackup", + ), + ): + recovery = Recovery(set_mocked_azure_provider()) + + recovery.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + recovery.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = recovery._get_vaults() + + assert mock_client.vaults.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_vaults_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.vaults = MagicMock() + mock_client.vaults.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.recovery.recovery_service.Recovery._get_vaults", + return_value={}, + ), + patch( + "prowler.providers.azure.services.recovery.recovery_service.RecoveryBackup", + ), + ): + recovery = Recovery(set_mocked_azure_provider()) + + recovery.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + recovery.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + recovery._get_vaults() + + mock_client.vaults.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) + + class Test_RecoveryBackup_Service: def test_get_backup_policies_lists_unprotected_vault_policies(self): policy = SimpleNamespace( diff --git a/tests/providers/azure/services/sqlserver/sqlserver_service_test.py b/tests/providers/azure/services/sqlserver/sqlserver_service_test.py index 4fc4f073fe..7da2fe8b58 100644 --- a/tests/providers/azure/services/sqlserver/sqlserver_service_test.py +++ b/tests/providers/azure/services/sqlserver/sqlserver_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from azure.mgmt.sql.models import ( EncryptionProtector, @@ -16,6 +16,8 @@ from prowler.providers.azure.services.sqlserver.sqlserver_service import ( ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -245,3 +247,100 @@ class Test_SqlServer_Service: ].security_alert_policies.state == "Disabled" ) + + +class Test_SQLServer_get_sql_servers: + def test_get_sql_servers_no_resource_groups(self): + mock_client = MagicMock() + mock_client.servers.list.return_value = [] + + with patch( + "prowler.providers.azure.services.sqlserver.sqlserver_service.SQLServer._get_sql_servers", + return_value={}, + ): + sql_server = SQLServer(set_mocked_azure_provider()) + + sql_server.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + sql_server.resource_groups = None + + result = sql_server._get_sql_servers() + + mock_client.servers.list.assert_called_once() + mock_client.servers.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_sql_servers_with_resource_group(self): + mock_client = MagicMock() + mock_client.servers.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.sqlserver.sqlserver_service.SQLServer._get_sql_servers", + return_value={}, + ): + sql_server = SQLServer(set_mocked_azure_provider()) + + sql_server.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + sql_server.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = sql_server._get_sql_servers() + + mock_client.servers.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.servers.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_sql_servers_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + + with patch( + "prowler.providers.azure.services.sqlserver.sqlserver_service.SQLServer._get_sql_servers", + return_value={}, + ): + sql_server = SQLServer(set_mocked_azure_provider()) + + sql_server.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + sql_server.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = sql_server._get_sql_servers() + + mock_client.servers.list_by_resource_group.assert_not_called() + mock_client.servers.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == [] + + def test_get_sql_servers_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.servers.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.sqlserver.sqlserver_service.SQLServer._get_sql_servers", + return_value={}, + ): + sql_server = SQLServer(set_mocked_azure_provider()) + + sql_server.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + sql_server.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = sql_server._get_sql_servers() + + assert mock_client.servers.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_sql_servers_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.servers.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.sqlserver.sqlserver_service.SQLServer._get_sql_servers", + return_value={}, + ): + sql_server = SQLServer(set_mocked_azure_provider()) + + sql_server.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + sql_server.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + sql_server._get_sql_servers() + + mock_client.servers.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/azure/services/storage/storage_service_test.py b/tests/providers/azure/services/storage/storage_service_test.py index 67fba33877..563b1b6a21 100644 --- a/tests/providers/azure/services/storage/storage_service_test.py +++ b/tests/providers/azure/services/storage/storage_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.azure.services.storage.storage_service import ( Account, @@ -11,6 +11,8 @@ from prowler.providers.azure.services.storage.storage_service import ( ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -387,3 +389,155 @@ class Test_Storage_Service_Retention_Policy_None_Handling: is False ) assert account.file_service_properties.share_delete_retention_policy.days == 0 + + +class Test_Storage_get_storage_accounts: + def test_get_storage_accounts_no_resource_groups(self): + mock_client = MagicMock() + mock_client.storage_accounts = MagicMock() + mock_client.storage_accounts.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_storage_accounts", + return_value={}, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_blob_properties", + return_value=None, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_file_share_properties", + return_value=None, + ), + ): + storage = Storage(set_mocked_azure_provider()) + + storage.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + storage.resource_groups = None + + result = storage._get_storage_accounts() + + mock_client.storage_accounts.list.assert_called_once() + mock_client.storage_accounts.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_storage_accounts_with_resource_group(self): + mock_client = MagicMock() + mock_client.storage_accounts = MagicMock() + mock_client.storage_accounts.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_storage_accounts", + return_value={}, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_blob_properties", + return_value=None, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_file_share_properties", + return_value=None, + ), + ): + storage = Storage(set_mocked_azure_provider()) + + storage.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + storage.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = storage._get_storage_accounts() + + mock_client.storage_accounts.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.storage_accounts.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_storage_accounts_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.storage_accounts = MagicMock() + + with ( + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_storage_accounts", + return_value={}, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_blob_properties", + return_value=None, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_file_share_properties", + return_value=None, + ), + ): + storage = Storage(set_mocked_azure_provider()) + + storage.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + storage.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = storage._get_storage_accounts() + + mock_client.storage_accounts.list_by_resource_group.assert_not_called() + mock_client.storage_accounts.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == [] + + def test_get_storage_accounts_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.storage_accounts = MagicMock() + mock_client.storage_accounts.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_storage_accounts", + return_value={}, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_blob_properties", + return_value=None, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_file_share_properties", + return_value=None, + ), + ): + storage = Storage(set_mocked_azure_provider()) + + storage.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + storage.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = storage._get_storage_accounts() + + assert mock_client.storage_accounts.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_storage_accounts_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.storage_accounts = MagicMock() + mock_client.storage_accounts.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_storage_accounts", + return_value={}, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_blob_properties", + return_value=None, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_file_share_properties", + return_value=None, + ), + ): + storage = Storage(set_mocked_azure_provider()) + + storage.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + storage.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + storage._get_storage_accounts() + + mock_client.storage_accounts.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/azure/services/vm/vm_service_test.py b/tests/providers/azure/services/vm/vm_service_test.py index 49b8045cf8..e25c6ffce3 100644 --- a/tests/providers/azure/services/vm/vm_service_test.py +++ b/tests/providers/azure/services/vm/vm_service_test.py @@ -14,6 +14,8 @@ from prowler.providers.azure.services.vm.vm_service import ( ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -465,3 +467,328 @@ class Test_VirtualMachine_SecurityProfile_Validation: assert isinstance(vm.security_profile.uefi_settings, UefiSettings) assert vm.security_profile.uefi_settings.secure_boot_enabled is True assert vm.security_profile.uefi_settings.v_tpm_enabled is True + + +class Test_VM_get_virtual_machines: + def test_get_virtual_machines_no_resource_groups(self): + mock_client = MagicMock() + mock_client.virtual_machines = MagicMock() + mock_client.virtual_machines.list_all.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = None + + result = vm_service._get_virtual_machines() + + mock_client.virtual_machines.list_all.assert_called_once() + mock_client.virtual_machines.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_virtual_machines_with_resource_group(self): + mock_client = MagicMock() + mock_client.virtual_machines = MagicMock() + mock_client.virtual_machines.list.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = vm_service._get_virtual_machines() + + mock_client.virtual_machines.list.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.virtual_machines.list_all.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_virtual_machines_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.virtual_machines = MagicMock() + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = vm_service._get_virtual_machines() + + mock_client.virtual_machines.list.assert_not_called() + mock_client.virtual_machines.list_all.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + +class Test_VM_get_disks: + def test_get_disks_no_resource_groups(self): + mock_client = MagicMock() + mock_client.disks = MagicMock() + mock_client.disks.list.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = None + + result = vm_service._get_disks() + + mock_client.disks.list.assert_called_once() + mock_client.disks.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_disks_with_resource_group(self): + mock_client = MagicMock() + mock_client.disks = MagicMock() + mock_client.disks.list_by_resource_group.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = vm_service._get_disks() + + mock_client.disks.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.disks.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_disks_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.disks = MagicMock() + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = vm_service._get_disks() + + mock_client.disks.list_by_resource_group.assert_not_called() + mock_client.disks.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + +class Test_VM_get_vm_scale_sets: + def test_get_vm_scale_sets_no_resource_groups(self): + mock_client = MagicMock() + mock_client.virtual_machine_scale_sets = MagicMock() + mock_client.virtual_machine_scale_sets.list_all.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = None + + result = vm_service._get_vm_scale_sets() + + mock_client.virtual_machine_scale_sets.list_all.assert_called_once() + mock_client.virtual_machine_scale_sets.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_vm_scale_sets_with_resource_group(self): + mock_client = MagicMock() + mock_client.virtual_machine_scale_sets = MagicMock() + mock_client.virtual_machine_scale_sets.list.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = vm_service._get_vm_scale_sets() + + mock_client.virtual_machine_scale_sets.list.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.virtual_machine_scale_sets.list_all.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_vm_scale_sets_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.virtual_machine_scale_sets = MagicMock() + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = vm_service._get_vm_scale_sets() + + mock_client.virtual_machine_scale_sets.list.assert_not_called() + mock_client.virtual_machine_scale_sets.list_all.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + def test_get_virtual_machines_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.virtual_machines = MagicMock() + mock_client.virtual_machines.list.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = vm_service._get_virtual_machines() + + assert mock_client.virtual_machines.list.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_virtual_machines_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.virtual_machines = MagicMock() + mock_client.virtual_machines.list.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + vm_service._get_virtual_machines() + + mock_client.virtual_machines.list.assert_called_once_with( + resource_group_name="RG" + ) + + +class Test_VM_get_disks_extra: + def test_get_disks_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.disks = MagicMock() + mock_client.disks.list_by_resource_group.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = vm_service._get_disks() + + assert mock_client.disks.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_disks_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.disks = MagicMock() + mock_client.disks.list_by_resource_group.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + vm_service._get_disks() + + mock_client.disks.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) + + +class Test_VM_get_vm_scale_sets_extra: + def test_get_vm_scale_sets_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.virtual_machine_scale_sets = MagicMock() + mock_client.virtual_machine_scale_sets.list.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = vm_service._get_vm_scale_sets() + + assert mock_client.virtual_machine_scale_sets.list.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_vm_scale_sets_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.virtual_machine_scale_sets = MagicMock() + mock_client.virtual_machine_scale_sets.list.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + vm_service._get_vm_scale_sets() + + mock_client.virtual_machine_scale_sets.list.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/external/test_dynamic_provider_loading.py b/tests/providers/external/test_dynamic_provider_loading.py index 78e308597d..ed367ad518 100644 --- a/tests/providers/external/test_dynamic_provider_loading.py +++ b/tests/providers/external/test_dynamic_provider_loading.py @@ -344,6 +344,84 @@ class TestProviderDiscovery: assert help_text["nohelptext"] == "" +class TestSdkOnly: + """The ``sdk_only`` flag (default True) and Provider.get_app_providers.""" + + def test_base_contract_defaults_to_sdk_only(self): + # The default must be True so nothing leaks into the app implicitly. + assert Provider.sdk_only is True + + def test_external_provider_without_flag_is_sdk_only(self): + # FakeExternalProvider does not override the flag -> inherits True. + assert FakeExternalProvider.sdk_only is True + + @patch("prowler.providers.common.provider.Provider.get_class") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_app_providers_filters_out_sdk_only( + self, mock_available, mock_get_class + ): + app_cls = type("AppProvider", (Provider,), {"sdk_only": False}) + sdk_cls = type("SdkProvider", (Provider,), {"sdk_only": True}) + mock_available.return_value = ["appone", "sdkone", "apptwo"] + mock_get_class.side_effect = lambda name: { + "appone": app_cls, + "sdkone": sdk_cls, + "apptwo": app_cls, + }[name] + + app_providers = Provider.get_app_providers() + + assert app_providers == ["appone", "apptwo"] + + @patch("prowler.providers.common.provider.Provider.get_class") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_app_providers_excludes_provider_that_fails_to_load( + self, mock_available, mock_get_class + ): + # A provider whose class cannot be imported is treated as sdk_only + # (excluded) so a broken plug-in never leaks into the app. + app_cls = type("AppProvider", (Provider,), {"sdk_only": False}) + mock_available.return_value = ["appone", "broken"] + + def _get_class(name): + if name == "broken": + raise ImportError("missing transitive dep") + return app_cls + + mock_get_class.side_effect = _get_class + + assert Provider.get_app_providers() == ["appone"] + + def test_app_exposed_builtins_declare_sdk_only_false(self): + # The providers implemented end-to-end in the API/UI must opt in. + app_providers = set(Provider.get_app_providers()) + for name in ( + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "mongodbatlas", + "iac", + "oraclecloud", + "alibabacloud", + "cloudflare", + "openstack", + "image", + "googleworkspace", + "vercel", + "okta", + ): + assert name in app_providers, f"{name} should be exposed to the app" + + def test_sdk_only_builtins_are_hidden_from_app(self): + # Built-ins not implemented in the API stay SDK-only via the default. + app_providers = set(Provider.get_app_providers()) + for name in ("llm", "nhn", "scaleway", "stackit"): + assert name not in app_providers, f"{name} must be hidden from the app" + + 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 @@ -1456,7 +1534,7 @@ class TestCompliance: dirs = _get_ep_compliance_dirs() - assert dirs["fakeexternal"] == "/path/to/compliance" + 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): @@ -1472,7 +1550,7 @@ class TestCompliance: dirs = _get_ep_compliance_dirs() - assert dirs["ext"] == "/path/to/compliance" + 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): @@ -1502,7 +1580,7 @@ class TestCompliance: with open(json_path, "w") as f: json.dump({"Framework": "Custom", "Provider": "ext"}, f) - mock_dirs.return_value = {"ext": tmpdir} + mock_dirs.return_value = {"ext": [tmpdir]} frameworks = get_available_compliance_frameworks("ext") @@ -2168,6 +2246,37 @@ class TestDispatchFallbacks: class TestBaseContractDefaults: """Tests for Provider base class default implementations.""" + def test_get_scan_arguments_passes_secret_through(self): + """Base get_scan_arguments returns the secret unchanged when no mutelist.""" + kwargs = FakeProviderNoHelpText.get_scan_arguments("uid", {"token": "x"}) + + assert kwargs == {"token": "x"} + + def test_get_scan_arguments_adds_mutelist_content(self): + """Base get_scan_arguments adds mutelist_content when provided.""" + kwargs = FakeProviderNoHelpText.get_scan_arguments( + "uid", {"token": "x"}, {"Mutelist": {}} + ) + + assert kwargs == {"token": "x", "mutelist_content": {"Mutelist": {}}} + + def test_get_scan_arguments_preserves_empty_mutelist_content(self): + """Base get_scan_arguments passes an explicit empty mutelist through so it + is not mistaken for an absent mutelist that triggers provider defaults.""" + kwargs = FakeProviderNoHelpText.get_scan_arguments("uid", {"token": "x"}, {}) + + assert kwargs == {"token": "x", "mutelist_content": {}} + + def test_get_connection_arguments_passes_secret_through(self): + """Base get_connection_arguments returns the secret unchanged.""" + kwargs = FakeProviderNoHelpText.get_connection_arguments("uid", {"token": "x"}) + + assert kwargs == {"token": "x"} + + def test_get_credentials_schema_defaults_to_empty(self): + """Base get_credentials_schema declares no schema by default.""" + assert FakeProviderNoHelpText.get_credentials_schema() == {} + def test_from_cli_args_raises_not_implemented(self): """Base Provider.from_cli_args raises NotImplementedError.""" with pytest.raises(NotImplementedError): diff --git a/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py b/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py index b4d385fdf1..c57393b314 100644 --- a/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py @@ -155,3 +155,123 @@ class Test_repository_default_branch_deletion_disabled_test: result[0].status_extended == f"Repository {repo_name} does deny default branch deletion." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=False, + branch_deletion_source="ruleset", + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ) + now = datetime.now(timezone.utc) + + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=default_branch, + private=False, + archived=False, + pushed_at=now, + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_deletion_disabled.repository_default_branch_deletion_disabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_deletion_disabled.repository_default_branch_deletion_disabled import ( + repository_default_branch_deletion_disabled, + ) + + check = repository_default_branch_deletion_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + branch_deletion_source="ruleset_not_active", + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ) + now = datetime.now(timezone.utc) + + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=default_branch, + private=False, + archived=False, + pushed_at=now, + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_deletion_disabled.repository_default_branch_deletion_disabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_deletion_disabled.repository_default_branch_deletion_disabled import ( + repository_default_branch_deletion_disabled, + ) + + check = repository_default_branch_deletion_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has default branch deletion disabled in a ruleset, but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py b/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py index b35393764e..81ab6bf0cc 100644 --- a/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py @@ -149,3 +149,119 @@ class Test_repository_default_branch_disallows_force_push_test: result[0].status_extended == f"Repository {repo_name} does deny force pushes on default branch ({default_branch})." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + allow_force_pushes_source="ruleset", + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_disallows_force_push.repository_default_branch_disallows_force_push.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_disallows_force_push.repository_default_branch_disallows_force_push import ( + repository_default_branch_disallows_force_push, + ) + + check = repository_default_branch_disallows_force_push() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + allow_force_pushes_source="ruleset_not_active", + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_disallows_force_push.repository_default_branch_disallows_force_push.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_disallows_force_push.repository_default_branch_disallows_force_push import ( + repository_default_branch_disallows_force_push, + ) + + check = repository_default_branch_disallows_force_push() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has force pushes disallowed in a ruleset on default branch ({default_branch}), but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py b/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py index 296c3d78e5..fbc7e22d68 100644 --- a/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py @@ -149,3 +149,119 @@ class Test_repository_default_branch_protection_applies_to_admins_test: result[0].status_extended == f"Repository {repo_name} does enforce administrators to be subject to the same branch protection rules as other users." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + enforce_admins_source="ruleset", + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_protection_applies_to_admins.repository_default_branch_protection_applies_to_admins.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_protection_applies_to_admins.repository_default_branch_protection_applies_to_admins import ( + repository_default_branch_protection_applies_to_admins, + ) + + check = repository_default_branch_protection_applies_to_admins() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + enforce_admins_source="ruleset_not_active", + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_protection_applies_to_admins.repository_default_branch_protection_applies_to_admins.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_protection_applies_to_admins.repository_default_branch_protection_applies_to_admins import ( + repository_default_branch_protection_applies_to_admins, + ) + + check = repository_default_branch_protection_applies_to_admins() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has a ruleset that would apply to administrators, but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py b/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py index 67bce91575..5fce4e7553 100644 --- a/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py @@ -149,3 +149,119 @@ class Test_repository_default_branch_protection_enabled_test: result[0].status_extended == f"Repository {repo_name} does enforce branch protection on default branch ({default_branch})." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + protected_source="ruleset", + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_protection_enabled.repository_default_branch_protection_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_protection_enabled.repository_default_branch_protection_enabled import ( + repository_default_branch_protection_enabled, + ) + + check = repository_default_branch_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=False, + protected_source="ruleset_not_active", + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_protection_enabled.repository_default_branch_protection_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_protection_enabled.repository_default_branch_protection_enabled import ( + repository_default_branch_protection_enabled, + ) + + check = repository_default_branch_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has a ruleset configured on default branch ({default_branch}), but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py index ab3d579b75..fc52676845 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py @@ -147,3 +147,117 @@ class Test_repository_default_branch_requires_codeowners_review: result[0].status_extended == f"Repository {repo_name} requires code owner approval for changes to owned code." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_code_owner_reviews_source="ruleset", + require_signed_commits=True, + conversation_resolution=True, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review import ( + repository_default_branch_requires_codeowners_review, + ) + + check = repository_default_branch_requires_codeowners_review() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_code_owner_reviews_source="ruleset_not_active", + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review import ( + repository_default_branch_requires_codeowners_review, + ) + + check = repository_default_branch_requires_codeowners_review() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has code owner approval configured in a ruleset, but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py index 8f5a82bbd8..d283fd1b26 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py @@ -149,3 +149,119 @@ class Test_repository_default_branch_requires_conversation_resolution_test: result[0].status_extended == f"Repository {repo_name} does require conversation resolution on default branch ({default_branch})." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + conversation_resolution_source="ruleset", + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_conversation_resolution.repository_default_branch_requires_conversation_resolution.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_conversation_resolution.repository_default_branch_requires_conversation_resolution import ( + repository_default_branch_requires_conversation_resolution, + ) + + check = repository_default_branch_requires_conversation_resolution() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + conversation_resolution_source="ruleset_not_active", + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_conversation_resolution.repository_default_branch_requires_conversation_resolution.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_conversation_resolution.repository_default_branch_requires_conversation_resolution import ( + repository_default_branch_requires_conversation_resolution, + ) + + check = repository_default_branch_requires_conversation_resolution() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has conversation resolution configured in a ruleset on default branch ({default_branch}), but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py index b072396804..99c6abcd29 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py @@ -149,3 +149,119 @@ class Test_repository_default_branch_requires_linear_history_test: result[0].status_extended == f"Repository {repo_name} does require linear history on default branch ({default_branch})." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + required_linear_history_source="ruleset", + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_linear_history.repository_default_branch_requires_linear_history.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_linear_history.repository_default_branch_requires_linear_history import ( + repository_default_branch_requires_linear_history, + ) + + check = repository_default_branch_requires_linear_history() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + required_linear_history_source="ruleset_not_active", + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_linear_history.repository_default_branch_requires_linear_history.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_linear_history.repository_default_branch_requires_linear_history import ( + repository_default_branch_requires_linear_history, + ) + + check = repository_default_branch_requires_linear_history() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has linear history configured in a ruleset on default branch ({default_branch}), but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py index ddb9e89df6..72fb5470a8 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py @@ -207,3 +207,117 @@ class Test_repository_default_branch_requires_multiple_approvals: result[0].status_extended == f"Repository {repo_name} does enforce at least 2 approvals for code changes." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=2, + approval_count_source="ruleset", + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_multiple_approvals.repository_default_branch_requires_multiple_approvals.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_multiple_approvals.repository_default_branch_requires_multiple_approvals import ( + repository_default_branch_requires_multiple_approvals, + ) + + check = repository_default_branch_requires_multiple_approvals() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=True, + approval_count=0, + approval_count_source="ruleset_not_active", + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_multiple_approvals.repository_default_branch_requires_multiple_approvals.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_multiple_approvals.repository_default_branch_requires_multiple_approvals import ( + repository_default_branch_requires_multiple_approvals, + ) + + check = repository_default_branch_requires_multiple_approvals() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has at least 2 approvals configured in a ruleset, but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits_test.py index 5785dd2a14..0f6f0112a7 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits_test.py @@ -149,3 +149,119 @@ class Test_repository_default_branch_requires_signed_commits: result[0].status_extended == f"Repository {repo_name} does require signed commits on default branch ({default_branch})." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + require_signed_commits_source="ruleset", + conversation_resolution=True, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits import ( + repository_default_branch_requires_signed_commits, + ) + + check = repository_default_branch_requires_signed_commits() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + require_signed_commits_source="ruleset_not_active", + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits import ( + repository_default_branch_requires_signed_commits, + ) + + check = repository_default_branch_requires_signed_commits() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has signed commits configured in a ruleset on default branch ({default_branch}), but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py b/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py index 1ec8acc3e0..da36b36865 100644 --- a/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py @@ -151,3 +151,121 @@ class Test_repository_default_branch_status_checks_required_test: result[0].status_extended == f"Repository {repo_name} does enforce status checks." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + private=False, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=True, + status_checks_source="ruleset", + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + status_checks=True, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_status_checks_required.repository_default_branch_status_checks_required.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_status_checks_required.repository_default_branch_status_checks_required import ( + repository_default_branch_status_checks_required, + ) + + check = repository_default_branch_status_checks_required() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + status_checks_source="ruleset_not_active", + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + status_checks=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + private=False, + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_status_checks_required.repository_default_branch_status_checks_required.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_status_checks_required.repository_default_branch_status_checks_required import ( + repository_default_branch_status_checks_required, + ) + + check = repository_default_branch_status_checks_required() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has status checks configured in a ruleset, but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file_test.py b/tests/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file_test.py index a181ec8404..d56aed307e 100644 --- a/tests/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file_test.py +++ b/tests/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file_test.py @@ -145,3 +145,55 @@ class Test_repository_has_codeowners_file: result[0].status_extended == f"Repository {repo_name} does have a CODEOWNERS file." ) + + def test_archived_repository_no_codeowners_is_skipped(self): + repository_client = mock.MagicMock + repo_name = "archived-repo" + repository_client.repositories = { + 3: Repo( + id=3, + name=repo_name, + owner="account-name", + full_name="account-name/archived-repo", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=False, + archived=True, + pushed_at=datetime.now(timezone.utc), + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_has_codeowners_file.repository_has_codeowners_file.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_has_codeowners_file.repository_has_codeowners_file import ( + repository_has_codeowners_file, + ) + + check = repository_has_codeowners_file() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/github/services/repository/repository_service_test.py b/tests/providers/github/services/repository/repository_service_test.py index 97924cbbd8..d0f3573e49 100644 --- a/tests/providers/github/services/repository/repository_service_test.py +++ b/tests/providers/github/services/repository/repository_service_test.py @@ -464,7 +464,7 @@ class Test_Repository_ErrorHandling: assert "Rate limit exceeded" in str(mock_logger.error.call_args) -class Test_Repository_DismissStaleReviewsRulesets: +class Test_Repository_BranchProtectionRulesets: def setup_method(self): self.repository_service = Repository.__new__(Repository) self.repository_service.provider = set_mocked_github_provider() @@ -550,6 +550,27 @@ class Test_Repository_DismissStaleReviewsRulesets: ], } + def _build_ruleset( + self, + *, + enforcement, + include, + rules, + bypass_actors=None, + ruleset_id=201, + ): + return { + "id": ruleset_id, + "name": "Branch protection ruleset", + "target": "branch", + "source_type": "Repository", + "source": "owner1/repo1", + "enforcement": enforcement, + "bypass_actors": bypass_actors or [], + "conditions": {"ref_name": {"include": include, "exclude": []}}, + "rules": rules, + } + def test_process_repository_uses_classic_branch_protection(self): repo = self._build_repo(branch_protected=True, dismiss_stale_reviews=True) repos = {} @@ -606,3 +627,320 @@ class Test_Repository_DismissStaleReviewsRulesets: assert ( repos[1].default_branch.dismiss_stale_reviews_source == "ruleset_not_active" ) + + def test_ruleset_non_fast_forward_disallows_force_push(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "non_fast_forward"}], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.allow_force_pushes is False + assert repos[1].default_branch.allow_force_pushes_source == "ruleset" + + def test_ruleset_non_fast_forward_inactive_keeps_force_push_allowed(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="disabled", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "non_fast_forward"}], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.allow_force_pushes is True + assert repos[1].default_branch.allow_force_pushes_source == "ruleset_not_active" + + def test_classic_protection_takes_precedence_over_inactive_ruleset(self): + # Classic protection already disallows force pushes, so an inactive ruleset + # must not downgrade the result. + repo = self._build_repo( + branch_protected=True, + ruleset_details=[ + self._build_ruleset( + enforcement="disabled", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "non_fast_forward"}], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.allow_force_pushes is False + assert repos[1].default_branch.allow_force_pushes_source == "classic" + + def test_ruleset_required_signatures_requires_signed_commits(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "required_signatures"}], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.require_signed_commits is True + assert repos[1].default_branch.require_signed_commits_source == "ruleset" + + def test_ruleset_required_linear_history(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "required_linear_history"}], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.required_linear_history is True + assert repos[1].default_branch.required_linear_history_source == "ruleset" + + def test_ruleset_required_status_checks(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[ + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [{"context": "ci/build"}], + "strict_required_status_checks_policy": True, + }, + } + ], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.status_checks is True + assert repos[1].default_branch.status_checks_source == "ruleset" + + def test_ruleset_required_status_checks_without_configured_checks(self): + # A required_status_checks rule with an empty list enforces nothing, so it + # must not be treated as a passing status-checks requirement. + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[ + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [], + "strict_required_status_checks_policy": True, + }, + } + ], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.status_checks is False + assert repos[1].default_branch.status_checks_source is None + + def test_ruleset_deletion_disables_branch_deletion(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "deletion"}], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.branch_deletion is False + assert repos[1].default_branch.branch_deletion_source == "ruleset" + + def test_active_ruleset_marks_default_branch_protected(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "non_fast_forward"}], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.protected is True + assert repos[1].default_branch.protected_source == "ruleset" + + def test_ruleset_pull_request_parameters_map_to_attributes(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[ + { + "type": "pull_request", + "parameters": { + "dismiss_stale_reviews_on_push": False, + "require_code_owner_review": True, + "require_last_push_approval": False, + "required_approving_review_count": 2, + "required_review_thread_resolution": True, + }, + } + ], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + branch = repos[1].default_branch + assert branch.require_pull_request is True + assert branch.require_pull_request_source == "ruleset" + assert branch.require_code_owner_reviews is True + assert branch.require_code_owner_reviews_source == "ruleset" + assert branch.conversation_resolution is True + assert branch.conversation_resolution_source == "ruleset" + assert branch.approval_count == 2 + assert branch.approval_count_source == "ruleset" + + def test_inactive_ruleset_approval_count_is_fail_signal(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="disabled", + include=["~DEFAULT_BRANCH"], + rules=[ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 2, + }, + } + ], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.approval_count == 0 + assert repos[1].default_branch.approval_count_source == "ruleset_not_active" + + def test_active_ruleset_without_bypass_actors_applies_to_admins(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "non_fast_forward"}], + bypass_actors=[], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.enforce_admins is True + assert repos[1].default_branch.enforce_admins_source == "ruleset" + + def test_active_ruleset_with_bypass_actors_does_not_apply_to_admins(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "non_fast_forward"}], + bypass_actors=[ + { + "actor_id": 1, + "actor_type": "RepositoryRole", + "bypass_mode": "always", + } + ], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + # Admins can bypass, so the rulesets do not enforce protection for them. + assert repos[1].default_branch.enforce_admins is False + assert repos[1].default_branch.enforce_admins_source is None + + def test_inactive_ruleset_with_bypass_actors_is_not_admin_fail_signal(self): + # A disabled ruleset that has bypass actors would not apply to admins even if + # activated, so it must not raise the enforce-admins ruleset_not_active signal. + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="disabled", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "non_fast_forward"}], + bypass_actors=[ + { + "actor_id": 1, + "actor_type": "RepositoryRole", + "bypass_mode": "always", + } + ], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.enforce_admins is False + assert repos[1].default_branch.enforce_admins_source is None + # The branch is still reported as protected-but-inactive regardless of bypass. + assert repos[1].default_branch.protected_source == "ruleset_not_active" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/entra_conditional_access_policy_groups_management_restricted_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/entra_conditional_access_policy_groups_management_restricted_test.py new file mode 100644 index 0000000000..db2c68be69 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/entra_conditional_access_policy_groups_management_restricted_test.py @@ -0,0 +1,269 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationEnforcedRestrictions, + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicy, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + Group, + PersistentBrowser, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE = ( + "prowler.providers.m365.services.entra." + "entra_conditional_access_policy_groups_management_restricted." + "entra_conditional_access_policy_groups_management_restricted.entra_client" +) + + +def _make_policy( + included_groups=None, + excluded_groups=None, + state=ConditionalAccessPolicyState.ENABLED, + display_name="Conditional Access Policy", +): + return ConditionalAccessPolicy( + id=str(uuid4()), + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=included_groups or [], + excluded_groups=excluded_groups or [], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=state, + ) + + +def _entra_client_mock(): + client = mock.MagicMock() + client.audited_tenant = "audited_tenant" + client.audited_domain = DOMAIN + client.groups = [] + client.conditional_access_policies = {} + return client + + +class Test_entra_conditional_access_policy_groups_management_restricted: + def test_no_enabled_or_report_only_policy_references_groups(self): + entra_client = _entra_client_mock() + entra_client.conditional_access_policies = { + "policy-1": _make_policy(state=ConditionalAccessPolicyState.DISABLED) + } + + 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_groups_management_restricted.entra_conditional_access_policy_groups_management_restricted import ( + entra_conditional_access_policy_groups_management_restricted, + ) + + check = entra_conditional_access_policy_groups_management_restricted() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "No enabled or report-only Conditional Access Policy references groups." + ) + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].location == "global" + + def test_policy_without_user_conditions_is_treated_as_no_referenced_groups(self): + entra_client = _entra_client_mock() + policy = _make_policy(display_name="Policy Without User Conditions") + policy.conditions.user_conditions = None + entra_client.conditional_access_policies = {"policy-1": policy} + + 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_groups_management_restricted.entra_conditional_access_policy_groups_management_restricted import ( + entra_conditional_access_policy_groups_management_restricted, + ) + + check = entra_conditional_access_policy_groups_management_restricted() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "No enabled or report-only Conditional Access Policy references groups." + ) + + def test_all_referenced_groups_are_protected(self): + entra_client = _entra_client_mock() + entra_client.groups = [ + Group( + id="group-1", + name="Restricted Group", + groupTypes=[], + membershipRule=None, + is_management_restricted=True, + ), + Group( + id="group-2", + name="Role Assignable Group", + groupTypes=[], + membershipRule=None, + is_assignable_to_role=True, + ), + ] + entra_client.conditional_access_policies = { + "policy-1": _make_policy( + included_groups=["group-1"], + excluded_groups=["group-2"], + display_name="Protected Policy", + ) + } + + 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_groups_management_restricted.entra_conditional_access_policy_groups_management_restricted import ( + entra_conditional_access_policy_groups_management_restricted, + ) + + check = entra_conditional_access_policy_groups_management_restricted() + result = check.execute() + + assert len(result) == 2 + assert {report.status for report in result} == {"PASS"} + assert {report.resource_id for report in result} == {"group-1", "group-2"} + for report in result: + assert "is management-restricted or role-assignable" in ( + report.status_extended + ) + + def test_unprotected_group_fails_with_include_and_exclude_usage(self): + entra_client = _entra_client_mock() + entra_client.groups = [ + Group( + id="group-1", + name="Unprotected Group", + groupTypes=[], + membershipRule=None, + ) + ] + entra_client.conditional_access_policies = { + "policy-1": _make_policy( + included_groups=["group-1"], + display_name="Include Policy", + ), + "policy-2": _make_policy( + excluded_groups=["group-1"], + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + display_name="Report Only Exclusion Policy", + ), + } + + 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_groups_management_restricted.entra_conditional_access_policy_groups_management_restricted import ( + entra_conditional_access_policy_groups_management_restricted, + ) + + check = entra_conditional_access_policy_groups_management_restricted() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "group-1" + assert result[0].resource_name == "Unprotected Group" + assert "Group Unprotected Group (group-1)" in result[0].status_extended + assert ( + "neither management-restricted nor role-assignable" + in result[0].status_extended + ) + assert "include policies: Include Policy" in result[0].status_extended + assert ( + "exclude policies: Report Only Exclusion Policy" + in result[0].status_extended + ) + + def test_unresolved_group_reference_is_manual(self): + entra_client = _entra_client_mock() + entra_client.conditional_access_policies = { + "policy-1": _make_policy( + excluded_groups=["deleted-group"], + display_name="Policy With Stale 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_groups_management_restricted.entra_conditional_access_policy_groups_management_restricted import ( + entra_conditional_access_policy_groups_management_restricted, + ) + + check = entra_conditional_access_policy_groups_management_restricted() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_id == "deleted-group" + assert "could not be resolved" in result[0].status_extended + assert "exclude policies: Policy With Stale Group" in result[0].status_extended diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/entra_conditional_access_policy_no_exclusion_gaps_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/entra_conditional_access_policy_no_exclusion_gaps_test.py new file mode 100644 index 0000000000..30629c2a29 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/entra_conditional_access_policy_no_exclusion_gaps_test.py @@ -0,0 +1,424 @@ +import re +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicy, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + PlatformConditions, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_no_exclusion_gaps.entra_conditional_access_policy_no_exclusion_gaps" +DIRECTORY_SYNC_ROLE_TEMPLATE_ID = "d29b2b05-8046-44ba-8758-1e26182fcf32" + + +def _policy( + display_name="Policy", + state=ConditionalAccessPolicyState.ENABLED, + included_users=None, + excluded_users=None, + included_groups=None, + excluded_groups=None, + included_roles=None, + excluded_roles=None, + included_applications=None, + excluded_applications=None, + include_platforms=None, + exclude_platforms=None, + block=False, +) -> ConditionalAccessPolicy: + """Build a fully-populated ConditionalAccessPolicy for tests. + + Args: + display_name: Policy display name. + state: Policy state (default ENABLED). + included_users: Included user IDs, or None. + excluded_users: Excluded user IDs, or None. + included_groups: Included group IDs, or None. + excluded_groups: Excluded group IDs, or None. + included_roles: Included role template IDs, or None. + excluded_roles: Excluded role template IDs, or None. + included_applications: Included application IDs, or None. + excluded_applications: Excluded application IDs, or None. + include_platforms: Included platform names, or None. + exclude_platforms: Excluded platform names, or None. + block: Whether the policy uses a Block grant control (default False). + + Returns: + A ConditionalAccessPolicy instance with the specified conditions. + """ + return ConditionalAccessPolicy( + id=str(uuid4()), + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=included_applications or [], + excluded_applications=excluded_applications or [], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=included_groups or [], + excluded_groups=excluded_groups or [], + included_users=included_users or [], + excluded_users=excluded_users or [], + included_roles=included_roles or [], + excluded_roles=excluded_roles or [], + ), + platform_conditions=PlatformConditions( + include_platforms=include_platforms or [], + exclude_platforms=exclude_platforms or [], + ), + ), + grant_controls=GrantControls( + built_in_controls=( + [ConditionalAccessGrantControl.BLOCK] + if block + else [ConditionalAccessGrantControl.MFA] + ), + operator=GrantControlOperator.AND, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ), + state=state, + ) + + +def _run( + policies: list[ConditionalAccessPolicy], + users=None, + groups=None, + service_principals=None, +) -> list: + """Run the check with a mocked entra_client holding the given policies. + + Args: + policies: ConditionalAccessPolicy objects to inject into the mocked client. + users: Optional id -> User mapping used to resolve display names. + groups: Optional list of Group objects used to resolve display names. + service_principals: Optional id -> ServicePrincipal mapping for app names. + + Returns: + The list of check report objects returned by ``execute()``. + """ + 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(f"{CHECK_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_exclusion_gaps.entra_conditional_access_policy_no_exclusion_gaps import ( + entra_conditional_access_policy_no_exclusion_gaps, + ) + + entra_client.conditional_access_policies = {p.id: p for p in policies} + entra_client.users = users or {} + entra_client.groups = groups or [] + entra_client.service_principals = service_principals or {} + check = entra_conditional_access_policy_no_exclusion_gaps() + return check.execute() + + +class Test_entra_conditional_access_policy_no_exclusion_gaps: + """Tests for the Conditional Access exclusion-gap check. + + Verifies that objects excluded from enabled Conditional Access policies stay + in scope of another enabled policy (explicitly or via the type's wildcard), + with the directory-sync role and break-glass accounts treated as intended + exclusions. + """ + + def test_no_policies(self): + result = _run([]) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "No enabled Conditional Access policies" in result[0].status_extended + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_only_disabled_policies(self): + result = _run( + [ + _policy( + state=ConditionalAccessPolicyState.DISABLED, + included_users=["All"], + excluded_users=["user-1"], + ) + ] + ) + assert result[0].status == "PASS" + assert "No enabled Conditional Access policies" in result[0].status_extended + + def test_report_only_policies_out_of_scope(self): + # An exclusion in a report-only policy must not be evaluated. + result = _run( + [ + _policy( + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + included_users=["All"], + excluded_users=["orphan-user"], + ) + ] + ) + assert result[0].status == "PASS" + assert "No enabled Conditional Access policies" in result[0].status_extended + + def test_no_exclusions_used(self): + result = _run([_policy(included_users=["All"], included_applications=["All"])]) + assert result[0].status == "PASS" + assert "no coverage gaps are possible" in result[0].status_extended + + def test_exclusion_covered_by_another_policy(self): + # Policy A excludes user-1; Policy B includes user-1 explicitly -> covered. + result = _run( + [ + _policy( + display_name="A", included_users=["All"], excluded_users=["user-1"] + ), + _policy(display_name="B", included_users=["user-1"]), + ] + ) + assert result[0].status == "PASS" + assert "compensating control" in result[0].status_extended + + def test_user_exclusion_gap(self): + # user-1 is excluded but never included anywhere -> FAIL. + result = _run( + [ + _policy( + display_name="A", included_users=["All"], excluded_users=["user-1"] + ) + ] + ) + assert result[0].status == "FAIL" + assert "users: user-1" in result[0].status_extended + + def test_gap_reports_display_name_when_resolvable(self): + # A resolvable user shows its display name; an unresolved user (e.g. + # deleted but still referenced) falls back to its raw ID. + from prowler.providers.m365.services.entra.entra_service import User + + result = _run( + [ + _policy( + display_name="A", + included_users=["All"], + excluded_users=["user-1", "ghost-2"], + ) + ], + users={ + "user-1": User( + id="user-1", + name="Alice Admin", + on_premises_sync_enabled=False, + ) + }, + ) + assert result[0].status == "FAIL" + assert "Alice Admin" in result[0].status_extended + assert "user-1" not in result[0].status_extended + # Unresolved ID still surfaces as the raw identifier. + assert "ghost-2" in result[0].status_extended + + def test_group_and_role_gaps_reported_by_type(self): + result = _run( + [ + _policy( + display_name="P", + included_users=["All"], + excluded_groups=["group-x"], + excluded_roles=["role-y"], + ) + ] + ) + assert result[0].status == "FAIL" + assert "groups: group-x" in result[0].status_extended + assert "roles: role-y" in result[0].status_extended + + def test_application_exclusion_gap(self): + result = _run( + [ + _policy( + display_name="AppPolicy", + included_applications=["All"], + excluded_applications=["app-123"], + ) + ] + ) + assert result[0].status == "FAIL" + assert "applications: app-123" in result[0].status_extended + + def test_application_exclusion_covered(self): + result = _run( + [ + _policy( + display_name="A", + included_applications=["All"], + excluded_applications=["app-123"], + ), + _policy(display_name="B", included_applications=["app-123"]), + ] + ) + assert result[0].status == "PASS" + + def test_exclusion_covered_by_all_wildcard_in_another_policy(self): + # Policy A excludes user-1; Policy B targets "All" users and does NOT + # exclude user-1, so user-1 stays in scope of B -> covered -> PASS. + # The "All" wildcard of the policy that excludes the user (A) must not + # count, but the wildcard of an unrelated policy (B) does. + result = _run( + [ + _policy( + display_name="A", + included_users=["All"], + excluded_users=["user-1"], + ), + _policy(display_name="B", included_users=["All"]), + ] + ) + assert result[0].status == "PASS" + assert "compensating control" in result[0].status_extended + + def test_exclusion_only_wildcard_is_self_excluding_is_gap(self): + # The only "All" users policy is the one that excludes user-1, and no + # other policy covers user-1 -> real gap -> FAIL. This is the case + # #11375's global-union "All" handling would have wrongly passed. + result = _run( + [ + _policy( + display_name="A", + included_users=["All"], + excluded_users=["user-1"], + ), + _policy( + display_name="B", + included_users=["All"], + excluded_users=["user-1"], + ), + ] + ) + assert result[0].status == "FAIL" + assert "users: user-1" in result[0].status_extended + + def test_platform_exclusions_are_ignored(self): + # Platform exclusions are scoping conditions, not principals removed from + # enforcement, so they are out of scope even with no covering policy. + result = _run( + [ + _policy( + display_name="MDM", + included_users=["All"], + exclude_platforms=["android", "ios", "macos", "linux"], + ) + ] + ) + assert result[0].status == "PASS" + + def test_directory_sync_role_exclusion_skipped(self): + # Dir-sync role excluded with no fallback must NOT be a gap. + result = _run( + [ + _policy( + display_name="P", + included_users=["All"], + excluded_roles=[DIRECTORY_SYNC_ROLE_TEMPLATE_ID], + ) + ] + ) + assert result[0].status == "PASS" + assert "compensating control" in result[0].status_extended + + def test_emergency_access_user_exclusion_skipped(self): + # A break-glass user excluded from EVERY enabled blocking policy is an + # intended gap and must not be reported. + emergency = "breakglass-user" + result = _run( + [ + _policy( + display_name="Block1", + block=True, + included_users=["All"], + excluded_users=[emergency], + ), + _policy( + display_name="Block2", + block=True, + included_users=["All"], + excluded_users=[emergency], + ), + ] + ) + assert result[0].status == "PASS" + assert "compensating control" in result[0].status_extended + + def test_emergency_access_ignores_report_only_blocking_policy(self): + # A break-glass user excluded from every ENABLED blocking policy is an + # intended gap, even if a report-only (non-enforced) blocking policy that + # does NOT exclude them also exists. Report-only policies must not dilute + # the emergency determination. + emergency = "breakglass-user" + result = _run( + [ + _policy( + display_name="Block1", + block=True, + included_users=["All"], + excluded_users=[emergency], + ), + _policy( + display_name="Block2", + block=True, + included_users=["All"], + excluded_users=[emergency], + ), + _policy( + display_name="ReportOnlyBlock", + block=True, + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + included_users=["All"], + ), + ] + ) + assert result[0].status == "PASS" + assert "compensating control" in result[0].status_extended + + def test_mixed_gap_and_covered(self): + # user-1 covered, user-2 orphaned -> FAIL listing only user-2. + result = _run( + [ + _policy( + display_name="A", + included_users=["All"], + excluded_users=["user-1", "user-2"], + ), + _policy(display_name="B", included_users=["user-1"]), + ] + ) + assert result[0].status == "FAIL" + assert "user-2" in result[0].status_extended + # user-1 is covered, so it must not appear as a gap (whitespace-robust). + assert not re.search(r"\busers:\s*user-1\b", result[0].status_extended) 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 ba6247c7bd..fb8a5e5e82 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -1,40 +1,41 @@ import asyncio +import importlib from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from prowler.providers.m365.models import M365IdentityInfo -from prowler.providers.m365.services.entra.entra_service import ( - AdminConsentPolicy, - AdminRoles, - ApplicationEnforcedRestrictions, - ApplicationsConditions, - AppManagementRestrictions, - AuthorizationPolicy, - AuthPolicyRoles, - ConditionalAccessGrantControl, - ConditionalAccessPolicy, - ConditionalAccessPolicyState, - Conditions, - CredentialRestriction, - DefaultAppManagementPolicy, - DefaultUserRolePermissions, - Entra, - GrantControlOperator, - GrantControls, - InvitationsFrom, - Organization, - PersistentBrowser, - SessionControls, - SignInFrequency, - SignInFrequencyInterval, - SignInFrequencyType, - User, - UserAction, - UsersConditions, -) +from prowler.providers.m365.services.entra import entra_service from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider +AdminConsentPolicy = entra_service.AdminConsentPolicy +AdminRoles = entra_service.AdminRoles +ApplicationEnforcedRestrictions = entra_service.ApplicationEnforcedRestrictions +ApplicationsConditions = entra_service.ApplicationsConditions +AppManagementRestrictions = entra_service.AppManagementRestrictions +AuthorizationPolicy = entra_service.AuthorizationPolicy +AuthPolicyRoles = entra_service.AuthPolicyRoles +ConditionalAccessGrantControl = entra_service.ConditionalAccessGrantControl +ConditionalAccessPolicy = entra_service.ConditionalAccessPolicy +ConditionalAccessPolicyState = entra_service.ConditionalAccessPolicyState +Conditions = entra_service.Conditions +CredentialRestriction = entra_service.CredentialRestriction +DefaultAppManagementPolicy = entra_service.DefaultAppManagementPolicy +DefaultUserRolePermissions = entra_service.DefaultUserRolePermissions +Entra = entra_service.Entra +GrantControlOperator = entra_service.GrantControlOperator +GrantControls = entra_service.GrantControls +InvitationsFrom = entra_service.InvitationsFrom +Organization = entra_service.Organization +PersistentBrowser = entra_service.PersistentBrowser +SessionControls = entra_service.SessionControls +SignInFrequency = entra_service.SignInFrequency +SignInFrequencyInterval = entra_service.SignInFrequencyInterval +SignInFrequencyType = entra_service.SignInFrequencyType +User = entra_service.User +UserAction = entra_service.UserAction +UsersConditions = entra_service.UsersConditions + async def mock_entra_get_authorization_policy(_): return AuthorizationPolicy( @@ -697,9 +698,12 @@ class Test_Entra_Service: a descriptive error message naming the missing AuditLog.Read.All permission. """ from msgraph.generated.models.o_data_errors.main_error import MainError - from msgraph.generated.models.o_data_errors.o_data_error import ODataError - odata_error = ODataError() + o_data_error = importlib.import_module( + "msgraph.generated.models.o_data_errors.o_data_error" + ) + + odata_error = o_data_error.ODataError() odata_error.error = MainError() odata_error.error.code = "Authorization_RequestDenied" @@ -879,6 +883,134 @@ class Test_Entra_Service: assert merged.password_credentials[0].display_name == "app-level-secret" assert merged.password_credentials[0].is_active() + def test__get_exchange_mailbox_permission_service_principals(self): + """Service principals with Exchange Graph application roles are returned.""" + graph_sp_id = "graph-sp-id" + mail_read_role_id = "11111111-1111-1111-1111-111111111111" + user_read_role_id = "22222222-2222-2222-2222-222222222222" + + graph_sp = SimpleNamespace( + id=graph_sp_id, + display_name="Microsoft Graph", + app_id="00000003-0000-0000-c000-000000000000", + app_owner_organization_id="f8cdef31-a31e-4b4a-93e4-5f571e91255a", + app_roles=[ + SimpleNamespace( + id=mail_read_role_id, + value="Mail.Read", + allowed_member_types=["Application"], + ), + SimpleNamespace( + id=user_read_role_id, + value="User.Read.All", + allowed_member_types=["Application"], + ), + ], + account_enabled=True, + service_principal_type="Application", + ) + mailbox_app = SimpleNamespace( + id="sp-mailbox", + display_name="Mailbox App", + app_id="app-mailbox", + app_owner_organization_id="33333333-3333-3333-3333-333333333333", + app_roles=[], + account_enabled=True, + service_principal_type="Application", + ) + disabled_app = SimpleNamespace( + id="sp-disabled", + display_name="Disabled App", + app_id="app-disabled", + app_owner_organization_id="33333333-3333-3333-3333-333333333333", + app_roles=[], + account_enabled=False, + service_principal_type="Application", + ) + first_party_app = SimpleNamespace( + id="sp-first-party", + display_name="Microsoft App", + app_id="app-first-party", + app_owner_organization_id="f8cdef31-a31e-4b4a-93e4-5f571e91255a", + app_roles=[], + account_enabled=True, + service_principal_type="Application", + ) + + app_role_assignments = { + "sp-mailbox": SimpleNamespace( + value=[ + SimpleNamespace( + resource_id=graph_sp_id, + app_role_id=mail_read_role_id, + ), + SimpleNamespace( + resource_id=graph_sp_id, + app_role_id=user_read_role_id, + ), + ], + odata_next_link=None, + ) + } + + def by_service_principal_id(service_principal_id): + return SimpleNamespace( + app_role_assignments=SimpleNamespace( + get=AsyncMock( + return_value=app_role_assignments.get( + service_principal_id, + SimpleNamespace(value=[], odata_next_link=None), + ) + ), + with_url=MagicMock(), + ) + ) + + entra_service = Entra.__new__(Entra) + entra_service.client = SimpleNamespace( + service_principals=SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace( + value=[graph_sp, mailbox_app, disabled_app, first_party_app], + odata_next_link=None, + ) + ), + with_url=MagicMock(), + by_service_principal_id=MagicMock(side_effect=by_service_principal_id), + ) + ) + + result = asyncio.run( + entra_service._get_exchange_mailbox_permission_service_principals() + ) + + assert set(result.keys()) == {"sp-mailbox"} + assert result["sp-mailbox"].app_id == "app-mailbox" + assert result["sp-mailbox"].exchange_mailbox_permissions == ["Mail.Read"] + + def test__get_exchange_mailbox_permission_service_principals_records_error(self): + """ + Graph collection failures preserve unavailable state separately from empty results. + """ + entra_service = Entra.__new__(Entra) + entra_service.client = SimpleNamespace( + service_principals=SimpleNamespace( + get=AsyncMock(side_effect=RuntimeError("Graph unavailable")) + ) + ) + + result = asyncio.run( + entra_service._get_exchange_mailbox_permission_service_principals() + ) + + assert result == {} + assert "RuntimeError" in ( + entra_service.exchange_mailbox_permission_service_principals_error + ) + assert "Graph unavailable" in ( + entra_service.exchange_mailbox_permission_service_principals_error + ) + def test__resolve_identifiers_for_type_flags_only_404(self): """Only HTTP 404 / Request_ResourceNotFound mark an id as deleted. diff --git a/tests/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps_test.py b/tests/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps_test.py new file mode 100644 index 0000000000..dc61d32ffb --- /dev/null +++ b/tests/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps_test.py @@ -0,0 +1,271 @@ +import importlib +from unittest import mock + +from prowler.providers.m365.services.entra import entra_service +from prowler.providers.m365.services.exchange import exchange_service +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE = ( + "prowler.providers.m365.services.exchange." + "exchange_application_access_policy_restricts_mailbox_apps." + "exchange_application_access_policy_restricts_mailbox_apps" +) + + +class Test_exchange_application_access_policy_restricts_mailbox_apps: + def test_powershell_unavailable_returns_manual(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + exchange_client.application_access_policies = None + + entra_client = mock.MagicMock() + entra_client.exchange_mailbox_permission_service_principals = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.entra_client", + new=entra_client, + ), + ): + check_module = importlib.import_module(CHECK_MODULE) + + result = ( + check_module.exchange_application_access_policy_restricts_mailbox_apps().execute() + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_id == "ExchangeOnlineTenant" + assert "Exchange Online PowerShell is unavailable" in result[0].status_extended + + def test_no_resources(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + exchange_client.application_access_policies = [] + + entra_client = mock.MagicMock() + entra_client.exchange_mailbox_permission_service_principals = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.entra_client", + new=entra_client, + ), + ): + check_module = importlib.import_module(CHECK_MODULE) + + result = ( + check_module.exchange_application_access_policy_restricts_mailbox_apps().execute() + ) + + assert len(result) == 0 + + def test_graph_collection_unavailable_returns_manual(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + exchange_client.application_access_policies = [] + + entra_client = mock.MagicMock() + entra_client.exchange_mailbox_permission_service_principals = {} + entra_client.exchange_mailbox_permission_service_principals_error = ( + "RuntimeError: Graph unavailable" + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.entra_client", + new=entra_client, + ), + ): + check_module = importlib.import_module(CHECK_MODULE) + + result = ( + check_module.exchange_application_access_policy_restricts_mailbox_apps().execute() + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_id == "ExchangeOnlineTenant" + assert ( + "Microsoft Graph mailbox permission collection failed" + in result[0].status_extended + ) + + def test_service_principal_without_policy_fails(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + exchange_client.application_access_policies = [] + + entra_client = mock.MagicMock() + entra_client.exchange_mailbox_permission_service_principals = { + "sp-id": entra_service.ServicePrincipal( + id="sp-id", + name="Mailbox App", + app_id="app-id", + exchange_mailbox_permissions=["Mail.Read", "Mail.Send"], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.entra_client", + new=entra_client, + ), + ): + check_module = importlib.import_module(CHECK_MODULE) + + result = ( + check_module.exchange_application_access_policy_restricts_mailbox_apps().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "sp-id" + assert result[0].resource_name == "Mailbox App" + assert "app-id" in result[0].status_extended + assert "Mail.Read, Mail.Send" in result[0].status_extended + + def test_service_principal_with_policy_passes(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + exchange_client.application_access_policies = [ + exchange_service.ApplicationAccessPolicy( + identity="policy-id", + app_id="app-id", + access_right="RestrictAccess", + description="Restrict mailbox access", + ) + ] + + entra_client = mock.MagicMock() + entra_client.exchange_mailbox_permission_service_principals = { + "sp-id": entra_service.ServicePrincipal( + id="sp-id", + name="Mailbox App", + app_id="app-id", + exchange_mailbox_permissions=["Mail.Read"], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.entra_client", + new=entra_client, + ), + ): + check_module = importlib.import_module(CHECK_MODULE) + + result = ( + check_module.exchange_application_access_policy_restricts_mailbox_apps().execute() + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "sp-id" + assert ( + "is restricted using an Application Access Policy" + in result[0].status_extended + ) + + def test_service_principal_with_deny_access_policy_fails(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + exchange_client.application_access_policies = [ + exchange_service.ApplicationAccessPolicy( + identity="policy-id", + app_id="app-id", + access_right="DenyAccess", + description="Deny mailbox access", + ) + ] + + entra_client = mock.MagicMock() + entra_client.exchange_mailbox_permission_service_principals = { + "sp-id": entra_service.ServicePrincipal( + id="sp-id", + name="Mailbox App", + app_id="app-id", + exchange_mailbox_permissions=["Mail.Read"], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.entra_client", + new=entra_client, + ), + ): + check_module = importlib.import_module(CHECK_MODULE) + + result = ( + check_module.exchange_application_access_policy_restricts_mailbox_apps().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "sp-id" + assert result[0].resource_name == "Mailbox App" + assert ( + "is not restricted using an Application Access Policy" + in result[0].status_extended + ) diff --git a/tests/providers/okta/lib/arguments/okta_arguments_test.py b/tests/providers/okta/lib/arguments/okta_arguments_test.py index 0e3fb9c1a1..b8d6456bb7 100644 --- a/tests/providers/okta/lib/arguments/okta_arguments_test.py +++ b/tests/providers/okta/lib/arguments/okta_arguments_test.py @@ -40,6 +40,8 @@ class TestOktaArguments: "--okta-org-domain", "--okta-client-id", "--okta-scopes", + "--okta-retries-max-attempts", + "--okta-requests-per-second", } def test_secret_flags_not_registered(self): diff --git a/tests/providers/okta/lib/service/okta_service_test.py b/tests/providers/okta/lib/service/okta_service_test.py new file mode 100644 index 0000000000..fb58a80104 --- /dev/null +++ b/tests/providers/okta/lib/service/okta_service_test.py @@ -0,0 +1,79 @@ +from unittest import mock + +from okta.http_client import HTTPClient + +from prowler.providers.okta.lib.service.rate_limiter import OktaRateLimiter +from prowler.providers.okta.lib.service.service import ( + DEFAULT_MAX_RETRIES, + DEFAULT_REQUEST_TIMEOUT, + OktaService, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def _build_service(audit_config: dict = None, rate_limiter=None): + """Instantiate OktaService with the SDK client patched, returning the + config dict that was handed to ``OktaSDKClient``.""" + provider = set_mocked_okta_provider( + audit_config=audit_config, rate_limiter=rate_limiter + ) + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + OktaService("test", provider) + return mocked_client_cls.call_args.args[0] + + +class Test_OktaService_set_client: + def test_defaults_applied_when_audit_config_empty(self): + config = _build_service(audit_config={}) + + assert config["rateLimit"] == {"maxRetries": DEFAULT_MAX_RETRIES} + assert config["requestTimeout"] == DEFAULT_REQUEST_TIMEOUT + + def test_defaults_applied_when_audit_config_none(self): + # set_mocked_okta_provider coerces None to {}, but the helper also + # guards against a None audit_config defensively. + config = _build_service(audit_config=None) + + assert config["rateLimit"] == {"maxRetries": DEFAULT_MAX_RETRIES} + assert config["requestTimeout"] == DEFAULT_REQUEST_TIMEOUT + + def test_audit_config_values_override_defaults(self): + config = _build_service( + audit_config={"okta_max_retries": 9, "okta_request_timeout": 120} + ) + + assert config["rateLimit"] == {"maxRetries": 9} + assert config["requestTimeout"] == 120 + + def test_retries_disabled_with_zero(self): + config = _build_service(audit_config={"okta_max_retries": 0}) + + assert config["rateLimit"] == {"maxRetries": 0} + + def test_preserves_session_sdk_config_keys(self): + config = _build_service(audit_config={}) + + # The rate-limit settings are layered on top of the shared session + # config, so the credential keys must remain intact. + assert config["orgUrl"] == "https://acme.okta.com" + assert config["authorizationMode"] == "PrivateKey" + assert config["clientId"] + assert config["privateKey"] + assert config["dpopEnabled"] is True + + def test_no_http_client_injected_without_limiter(self): + config = _build_service(audit_config={}) + + assert "httpClient" not in config + + def test_throttled_http_client_injected_with_limiter(self): + limiter = OktaRateLimiter(4) + config = _build_service(audit_config={}, rate_limiter=limiter) + + # The SDK instantiates the class itself, so a throttled HTTPClient + # subclass must be injected (not an instance). + http_client_cls = config["httpClient"] + assert isinstance(http_client_cls, type) + assert issubclass(http_client_cls, HTTPClient) diff --git a/tests/providers/okta/lib/service/rate_limiter_test.py b/tests/providers/okta/lib/service/rate_limiter_test.py new file mode 100644 index 0000000000..266cb6d085 --- /dev/null +++ b/tests/providers/okta/lib/service/rate_limiter_test.py @@ -0,0 +1,99 @@ +import asyncio +from unittest import mock + +import pytest + +from prowler.providers.okta.lib.service.rate_limiter import ( + OktaRateLimiter, + build_throttled_http_client, +) + + +class FakeClock: + """Deterministic clock whose `sleep` advances time instead of waiting.""" + + def __init__(self): + self.now = 0.0 + self.sleeps = [] + + def __call__(self): + return self.now + + async def sleep(self, seconds): + self.sleeps.append(seconds) + self.now += seconds + + +def _limiter(rate, clock): + return OktaRateLimiter(rate, clock=clock, sleep=clock.sleep) + + +class Test_OktaRateLimiter: + def test_rejects_non_positive_rate(self): + with pytest.raises(ValueError): + OktaRateLimiter(0) + with pytest.raises(ValueError): + OktaRateLimiter(-1) + + def test_initial_burst_does_not_sleep(self): + clock = FakeClock() + # capacity == rate == 2, so the first two tokens are free. + limiter = _limiter(2, clock) + + asyncio.run(limiter.acquire()) + asyncio.run(limiter.acquire()) + + assert clock.sleeps == [] + + def test_sleeps_to_maintain_rate_once_bucket_drained(self): + clock = FakeClock() + limiter = _limiter(2, clock) # capacity 2, refill 2/s + + # Drain the burst, then the third call must wait one refill interval. + asyncio.run(limiter.acquire()) + asyncio.run(limiter.acquire()) + asyncio.run(limiter.acquire()) + + assert clock.sleeps == [pytest.approx(0.5)] + assert clock.now == pytest.approx(0.5) + + def test_elapsed_time_refills_tokens_without_sleeping(self): + clock = FakeClock() + limiter = _limiter(2, clock) + + asyncio.run(limiter.acquire()) + asyncio.run(limiter.acquire()) + # Enough wall-clock passes to fully refill the bucket. + clock.now += 1.0 + asyncio.run(limiter.acquire()) + + assert clock.sleeps == [] + + def test_rate_below_one_per_second(self): + clock = FakeClock() + limiter = _limiter(0.5, clock) # capacity floored to 1.0 + + asyncio.run(limiter.acquire()) # free initial token + asyncio.run(limiter.acquire()) # must wait 1 / 0.5 = 2s + + assert clock.sleeps == [pytest.approx(2.0)] + + +class Test_build_throttled_http_client: + def test_acquires_before_delegating_to_super(self): + limiter = mock.MagicMock() + limiter.acquire = mock.AsyncMock() + + throttled_cls = build_throttled_http_client(limiter) + client = throttled_cls({"headers": {}}) + + with mock.patch.object( + throttled_cls.__bases__[0], + "send_request", + new=mock.AsyncMock(return_value="response"), + ) as base_send: + result = asyncio.run(client.send_request({"method": "GET"})) + + limiter.acquire.assert_awaited_once() + base_send.assert_awaited_once_with({"method": "GET"}) + assert result == "response" diff --git a/tests/providers/okta/okta_fixtures.py b/tests/providers/okta/okta_fixtures.py index 5c3d0e43c3..4113a2fdfb 100644 --- a/tests/providers/okta/okta_fixtures.py +++ b/tests/providers/okta/okta_fixtures.py @@ -11,6 +11,7 @@ def set_mocked_okta_provider( session: OktaSession = None, identity: OktaIdentityInfo = None, audit_config: dict = None, + rate_limiter=None, ): if session is None: session = OktaSession( @@ -54,4 +55,7 @@ def set_mocked_okta_provider( provider.session = session provider.identity = identity provider.audit_config = audit_config or {} + # Default to no throttling so service tests build a plain SDK client; tests + # that exercise the limiter pass one explicitly. + provider.rate_limiter = rate_limiter return provider diff --git a/tests/providers/okta/okta_provider_test.py b/tests/providers/okta/okta_provider_test.py index 5f2757edae..91d5607fdd 100644 --- a/tests/providers/okta/okta_provider_test.py +++ b/tests/providers/okta/okta_provider_test.py @@ -497,6 +497,85 @@ class Test_OktaProvider_init: assert provider.audit_config is not None assert provider.mutelist is not None + def test_default_max_retries_from_config_file(self, _clear_okta_env, tmp_path): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + provider = OktaProvider( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + ) + + # No CLI override: value comes from the bundled config.yaml default. + assert provider.audit_config["okta_max_retries"] == 5 + + def test_cli_retries_override_config_file(self, _clear_okta_env, tmp_path): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + provider = OktaProvider( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + okta_retries_max_attempts=9, + ) + + assert provider.audit_config["okta_max_retries"] == 9 + + def test_cli_retries_override_accepts_zero(self, _clear_okta_env, tmp_path): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + provider = OktaProvider( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + okta_retries_max_attempts=0, + ) + + # 0 disables retries and must not be treated as "unset". + assert provider.audit_config["okta_max_retries"] == 0 + + def test_rate_limiter_built_from_config_file_default( + self, _clear_okta_env, tmp_path + ): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + provider = OktaProvider( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + ) + + # Bundled config.yaml enables throttling at 4 req/s. + assert provider.rate_limiter is not None + assert provider.audit_config["okta_requests_per_second"] == 4 + + def test_cli_requests_per_second_override(self, _clear_okta_env, tmp_path): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + provider = OktaProvider( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + okta_requests_per_second=10, + ) + + assert provider.audit_config["okta_requests_per_second"] == 10 + assert provider.rate_limiter is not None + + def test_requests_per_second_zero_disables_throttling( + self, _clear_okta_env, tmp_path + ): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + provider = OktaProvider( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + okta_requests_per_second=0, + ) + + assert provider.rate_limiter is None + class Test_OktaProvider_test_connection: def test_success(self, _clear_okta_env, tmp_path): diff --git a/tests/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data_test.py b/tests/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data_test.py index 6be9ac48f5..85b97da0a2 100644 --- a/tests/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data_test.py +++ b/tests/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data_test.py @@ -2,6 +2,7 @@ from unittest import mock +from prowler.lib.check.models import Severity from prowler.providers.openstack.services.blockstorage.blockstorage_service import ( SnapshotResource, ) @@ -141,7 +142,7 @@ class Test_blockstorage_snapshot_metadata_sensitive_data: status="available", size=50, volume_id="vol-1", - metadata={"db_password": "supersecret123"}, + metadata={"db_password": "Tr0ub4dor3xKq9vLmZ"}, project_id=OPENSTACK_PROJECT_ID, region=OPENSTACK_REGION, ) @@ -179,7 +180,9 @@ class Test_blockstorage_snapshot_metadata_sensitive_data: status="available", size=50, volume_id="vol-1", - metadata={"api_key": "sk-1234567890"}, + metadata={ + "api_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + }, project_id=OPENSTACK_PROJECT_ID, region=OPENSTACK_REGION, ) @@ -223,7 +226,9 @@ class Test_blockstorage_snapshot_metadata_sensitive_data: status="available", size=50, volume_id="vol-1", - metadata={"ssh_key": "-----BEGIN RSA PRIVATE KEY-----"}, + metadata={ + "ssh_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCUzlT9QGi8ZSr5\nk+LTRz/1TaiCCs6o1icW4cur0Q0hdBnbRJXUdjlQsgzmBvCBNkGHI8hb/RUPssvc\nDLU5kOQ3Wp2KgtbphhZ2PfpuJrzwHL1ejcJkRxegm/aTdmpoQKcxGeehAfHbmlLA\nxdfn6wPDfGji973yiRH56JRukJAaqF50HC2a/AVNC5HtZoVlbQ+WvVbYVUnPxNkv\nPpc53PjrBgWiTtdMONEqJ3jDiaqfUBt+TZYF0CFc9HgjnUniRX28OukDyLu+idOz\nFKyZxMXtqexkAvQLDW1PATpZgVQ7hJoCD8UVTXAtcgzPq5fA6AR2URiECHI6ZyL0\nUmixKfMNAgMBAAECggEAJRzp5wjdpmEgDQOkjpfGXJ6sAJUD8mmI8cTKeJWIzhdo\nDH8oVEdRJ65kl6lS6hMXWEZlJgYyrsnj3MPBnjQkKycbRCy6P59s8jwmfbsFI+iz\nFUZLXZm6i5jicGhYBRzc5hrlIYu73863RXOClAnSFDsu6K6rzfYASQFIJeRBwJfs\njqXinuun/h2zGjpiY+TtNsa8c+nC7f3sGsTzNJugDvBPWQzsnAMzXJqiyharre4V\no157XIOvdC0joIp8j/Ib1ZtMfz1K1LcgBgw0szSieIw0Rq8yQ0Ek7GtLh43jG+ap\nvcSEesTD1p4mjPXoWkPG8KYd4iwGedZaePfheVcKKQKBgQDNE03SWv18AH0d4fpB\nlFAtRybCfSvMORzBrt2oilz8wDmK+Zga5o+phCnM8v3eJy1v8BvIQ9RvwQA2uVgZ\nr701wNMpVrTsMujk83oVRhimZLk6Hyw07wmMgEHX7+izkm2Lk4Lk7Zol3VRfnWG6\nmIcUk7xB1yAs3mudsfx0VO0QyQKBgQC5wfdqCLj2hZk4sMZu8Bth+BHKChGItmDk\nAW7aNt+gaPyoryOJoi2OUO8ud8EyuqXiuslSk2pPtjvLhCppkoq6V8kmPAUzaxFk\n4nDEAxT9Un8IJ0j2ebv+koQKsBWjssbVSjrZgIcYIDK1QblgbCp2FSE3ima+V8ip\nOdNjiatWJQKBgEX8lox5nRSanhh6rIuA8DPjmmi5ix7xRs0avm7seXuQppK1R6G2\nmcTCY/mb2+Pa/vi6uuCHtZJGDaqfal+pyCr2GZp8CtapMS4hocJs37C5ozUguld+\nVIXsp4voRkQybsw5lWxHYloVxNu0vEuQDlmJabAWmNZ3OcbhnUSeTyFxAoGAFtkZ\n0owCHChwoT11Gt4jsBgwL/avE27DWigm92Y6eWOQeDsalupAyjmAQenu9Itqrgml\ni6egMu/KSQ0Xnmas86CqmC5XwWxQ9mS31BRA96u2/ky+t7pfej+RSDNCZiEuPbvk\noy4g78G+GvdbktWbH20X6dn3K0Bm6RG4w4yCa5UCgYBs0zAVs0DZmM8SUZJA/HuQ\nN6a1vKKns7xKw5N3SmX1KbDhx5LSZXfbUo2+QktE7iRf9G2f1o0q8kz9l/4AGXi1\nKJNUHupWoaQzGNrzAb27TUtFA0ocMG8KnqxjANWox5oPJS9OU5tw5H5dxeI/Senc\nkYW6eCnRzPcmBqex6Vuw4w==\n-----END PRIVATE KEY-----\n" + }, project_id=OPENSTACK_PROJECT_ID, region=OPENSTACK_REGION, ) @@ -277,7 +282,7 @@ class Test_blockstorage_snapshot_metadata_sensitive_data: status="available", size=50, volume_id="vol-2", - metadata={"admin_password": "secret123"}, + metadata={"admin_password": "Tr0ub4dor3xKq9vLmZ"}, project_id=OPENSTACK_PROJECT_ID, region=OPENSTACK_REGION, ), @@ -318,7 +323,7 @@ class Test_blockstorage_snapshot_metadata_sensitive_data: metadata={ "environment": "production", "application": "web-app", - "db_password": "supersecret123", + "db_password": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "region": "us-east", }, project_id=OPENSTACK_PROJECT_ID, @@ -348,3 +353,57 @@ class Test_blockstorage_snapshot_metadata_sensitive_data: # Verify the secret is correctly attributed to 'db_password' key assert "in metadata key 'db_password'" in result[0].status_extended assert result[0].resource_id == "snap-6" + + def test_snapshot_verified_secret_escalates_to_critical(self): + """Test that a confirmed live secret escalates the finding to CRITICAL (FAIL).""" + blockstorage_client = mock.MagicMock() + blockstorage_client.audit_config = {"secrets_validate": True} + blockstorage_client.snapshots = [ + SnapshotResource( + id="snap-verified", + name="Verified Secret", + status="available", + size=50, + volume_id="vol-1", + metadata={"api_key": "placeholder"}, + project_id=OPENSTACK_PROJECT_ID, + region=OPENSTACK_REGION, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_openstack_provider(), + ), + mock.patch( + "prowler.providers.openstack.services.blockstorage.blockstorage_snapshot_metadata_sensitive_data.blockstorage_snapshot_metadata_sensitive_data.blockstorage_client", + new=blockstorage_client, + ), + mock.patch( + "prowler.providers.openstack.services.blockstorage.blockstorage_snapshot_metadata_sensitive_data.blockstorage_snapshot_metadata_sensitive_data.detect_secrets_scan_batch", + return_value={ + 0: [ + { + "type": "JSON Web Token (base64url-encoded)", + "line_number": 2, + "filename": "data", + "hashed_secret": "x", + "is_verified": True, + } + ] + }, + ) as mock_scan, + ): + from prowler.providers.openstack.services.blockstorage.blockstorage_snapshot_metadata_sensitive_data.blockstorage_snapshot_metadata_sensitive_data import ( + blockstorage_snapshot_metadata_sensitive_data, + ) + + check = blockstorage_snapshot_metadata_sensitive_data() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "confirmed to be live" in result[0].status_extended + assert mock_scan.call_args.kwargs.get("validate") is True diff --git a/tests/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data_test.py b/tests/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data_test.py index e12babb23c..80927e2f9d 100644 --- a/tests/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data_test.py +++ b/tests/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data_test.py @@ -2,6 +2,7 @@ from unittest import mock +from prowler.lib.check.models import Severity from prowler.providers.openstack.services.blockstorage.blockstorage_service import ( VolumeResource, ) @@ -159,7 +160,7 @@ class Test_blockstorage_volume_metadata_sensitive_data: is_bootable=False, is_multiattach=False, attachments=[], - metadata={"db_password": "supersecret123"}, + metadata={"db_password": "Tr0ub4dor3xKq9vLmZ"}, availability_zone="nova", snapshot_id="", source_volume_id="", @@ -204,7 +205,9 @@ class Test_blockstorage_volume_metadata_sensitive_data: is_bootable=False, is_multiattach=False, attachments=[], - metadata={"api_key": "sk-1234567890"}, + metadata={ + "api_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + }, availability_zone="nova", snapshot_id="", source_volume_id="", @@ -255,7 +258,9 @@ class Test_blockstorage_volume_metadata_sensitive_data: is_bootable=False, is_multiattach=False, attachments=[], - metadata={"ssh_key": "-----BEGIN RSA PRIVATE KEY-----"}, + metadata={ + "ssh_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCUzlT9QGi8ZSr5\nk+LTRz/1TaiCCs6o1icW4cur0Q0hdBnbRJXUdjlQsgzmBvCBNkGHI8hb/RUPssvc\nDLU5kOQ3Wp2KgtbphhZ2PfpuJrzwHL1ejcJkRxegm/aTdmpoQKcxGeehAfHbmlLA\nxdfn6wPDfGji973yiRH56JRukJAaqF50HC2a/AVNC5HtZoVlbQ+WvVbYVUnPxNkv\nPpc53PjrBgWiTtdMONEqJ3jDiaqfUBt+TZYF0CFc9HgjnUniRX28OukDyLu+idOz\nFKyZxMXtqexkAvQLDW1PATpZgVQ7hJoCD8UVTXAtcgzPq5fA6AR2URiECHI6ZyL0\nUmixKfMNAgMBAAECggEAJRzp5wjdpmEgDQOkjpfGXJ6sAJUD8mmI8cTKeJWIzhdo\nDH8oVEdRJ65kl6lS6hMXWEZlJgYyrsnj3MPBnjQkKycbRCy6P59s8jwmfbsFI+iz\nFUZLXZm6i5jicGhYBRzc5hrlIYu73863RXOClAnSFDsu6K6rzfYASQFIJeRBwJfs\njqXinuun/h2zGjpiY+TtNsa8c+nC7f3sGsTzNJugDvBPWQzsnAMzXJqiyharre4V\no157XIOvdC0joIp8j/Ib1ZtMfz1K1LcgBgw0szSieIw0Rq8yQ0Ek7GtLh43jG+ap\nvcSEesTD1p4mjPXoWkPG8KYd4iwGedZaePfheVcKKQKBgQDNE03SWv18AH0d4fpB\nlFAtRybCfSvMORzBrt2oilz8wDmK+Zga5o+phCnM8v3eJy1v8BvIQ9RvwQA2uVgZ\nr701wNMpVrTsMujk83oVRhimZLk6Hyw07wmMgEHX7+izkm2Lk4Lk7Zol3VRfnWG6\nmIcUk7xB1yAs3mudsfx0VO0QyQKBgQC5wfdqCLj2hZk4sMZu8Bth+BHKChGItmDk\nAW7aNt+gaPyoryOJoi2OUO8ud8EyuqXiuslSk2pPtjvLhCppkoq6V8kmPAUzaxFk\n4nDEAxT9Un8IJ0j2ebv+koQKsBWjssbVSjrZgIcYIDK1QblgbCp2FSE3ima+V8ip\nOdNjiatWJQKBgEX8lox5nRSanhh6rIuA8DPjmmi5ix7xRs0avm7seXuQppK1R6G2\nmcTCY/mb2+Pa/vi6uuCHtZJGDaqfal+pyCr2GZp8CtapMS4hocJs37C5ozUguld+\nVIXsp4voRkQybsw5lWxHYloVxNu0vEuQDlmJabAWmNZ3OcbhnUSeTyFxAoGAFtkZ\n0owCHChwoT11Gt4jsBgwL/avE27DWigm92Y6eWOQeDsalupAyjmAQenu9Itqrgml\ni6egMu/KSQ0Xnmas86CqmC5XwWxQ9mS31BRA96u2/ky+t7pfej+RSDNCZiEuPbvk\noy4g78G+GvdbktWbH20X6dn3K0Bm6RG4w4yCa5UCgYBs0zAVs0DZmM8SUZJA/HuQ\nN6a1vKKns7xKw5N3SmX1KbDhx5LSZXfbUo2+QktE7iRf9G2f1o0q8kz9l/4AGXi1\nKJNUHupWoaQzGNrzAb27TUtFA0ocMG8KnqxjANWox5oPJS9OU5tw5H5dxeI/Senc\nkYW6eCnRzPcmBqex6Vuw4w==\n-----END PRIVATE KEY-----\n" + }, availability_zone="nova", snapshot_id="", source_volume_id="", @@ -323,7 +328,7 @@ class Test_blockstorage_volume_metadata_sensitive_data: is_bootable=False, is_multiattach=False, attachments=[], - metadata={"admin_password": "secret123"}, + metadata={"admin_password": "Tr0ub4dor3xKq9vLmZ"}, availability_zone="nova", snapshot_id="", source_volume_id="", @@ -371,7 +376,7 @@ class Test_blockstorage_volume_metadata_sensitive_data: metadata={ "environment": "production", "application": "web-app", - "db_password": "supersecret123", + "db_password": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "region": "us-east", }, availability_zone="nova", @@ -404,3 +409,64 @@ class Test_blockstorage_volume_metadata_sensitive_data: # Verify the secret is correctly attributed to 'db_password' key assert "in metadata key 'db_password'" in result[0].status_extended assert result[0].resource_id == "vol-6" + + def test_volume_verified_secret_escalates_to_critical(self): + """Test that a confirmed live secret escalates the finding to CRITICAL (FAIL).""" + blockstorage_client = mock.MagicMock() + blockstorage_client.audit_config = {"secrets_validate": True} + blockstorage_client.volumes = [ + VolumeResource( + id="vol-verified", + name="Verified Secret", + status="in-use", + size=100, + volume_type="standard", + is_encrypted=False, + is_bootable=False, + is_multiattach=False, + attachments=[], + metadata={"api_key": "placeholder"}, + availability_zone="nova", + snapshot_id="", + source_volume_id="", + project_id=OPENSTACK_PROJECT_ID, + region=OPENSTACK_REGION, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_openstack_provider(), + ), + mock.patch( + "prowler.providers.openstack.services.blockstorage.blockstorage_volume_metadata_sensitive_data.blockstorage_volume_metadata_sensitive_data.blockstorage_client", + new=blockstorage_client, + ), + mock.patch( + "prowler.providers.openstack.services.blockstorage.blockstorage_volume_metadata_sensitive_data.blockstorage_volume_metadata_sensitive_data.detect_secrets_scan_batch", + return_value={ + 0: [ + { + "type": "JSON Web Token (base64url-encoded)", + "line_number": 2, + "filename": "data", + "hashed_secret": "x", + "is_verified": True, + } + ] + }, + ) as mock_scan, + ): + from prowler.providers.openstack.services.blockstorage.blockstorage_volume_metadata_sensitive_data.blockstorage_volume_metadata_sensitive_data import ( + blockstorage_volume_metadata_sensitive_data, + ) + + check = blockstorage_volume_metadata_sensitive_data() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "confirmed to be live" in result[0].status_extended + assert mock_scan.call_args.kwargs.get("validate") is True diff --git a/tests/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data_test.py b/tests/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data_test.py index 174a8ab83f..daaffffb1f 100644 --- a/tests/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data_test.py +++ b/tests/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data_test.py @@ -2,6 +2,7 @@ from unittest import mock +from prowler.lib.check.models import Severity from prowler.providers.openstack.services.compute.compute_service import ComputeInstance from tests.providers.openstack.openstack_fixtures import ( OPENSTACK_PROJECT_ID, @@ -181,7 +182,7 @@ class Test_compute_instance_metadata_sensitive_data: private_v6="", networks={}, has_config_drive=False, - metadata={"db_password": "supersecret123"}, + metadata={"db_password": "Tr0ub4dor3xKq9vLmZ"}, user_data="", trusted_image_certificates=[], ) @@ -233,7 +234,9 @@ class Test_compute_instance_metadata_sensitive_data: private_v6="", networks={}, has_config_drive=False, - metadata={"api_key": "sk-1234567890"}, + metadata={ + "api_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + }, user_data="", trusted_image_certificates=[], ) @@ -349,7 +352,9 @@ class Test_compute_instance_metadata_sensitive_data: private_v6="", networks={}, has_config_drive=False, - metadata={"ssh_key": "-----BEGIN RSA PRIVATE KEY-----"}, + metadata={ + "ssh_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCUzlT9QGi8ZSr5\nk+LTRz/1TaiCCs6o1icW4cur0Q0hdBnbRJXUdjlQsgzmBvCBNkGHI8hb/RUPssvc\nDLU5kOQ3Wp2KgtbphhZ2PfpuJrzwHL1ejcJkRxegm/aTdmpoQKcxGeehAfHbmlLA\nxdfn6wPDfGji973yiRH56JRukJAaqF50HC2a/AVNC5HtZoVlbQ+WvVbYVUnPxNkv\nPpc53PjrBgWiTtdMONEqJ3jDiaqfUBt+TZYF0CFc9HgjnUniRX28OukDyLu+idOz\nFKyZxMXtqexkAvQLDW1PATpZgVQ7hJoCD8UVTXAtcgzPq5fA6AR2URiECHI6ZyL0\nUmixKfMNAgMBAAECggEAJRzp5wjdpmEgDQOkjpfGXJ6sAJUD8mmI8cTKeJWIzhdo\nDH8oVEdRJ65kl6lS6hMXWEZlJgYyrsnj3MPBnjQkKycbRCy6P59s8jwmfbsFI+iz\nFUZLXZm6i5jicGhYBRzc5hrlIYu73863RXOClAnSFDsu6K6rzfYASQFIJeRBwJfs\njqXinuun/h2zGjpiY+TtNsa8c+nC7f3sGsTzNJugDvBPWQzsnAMzXJqiyharre4V\no157XIOvdC0joIp8j/Ib1ZtMfz1K1LcgBgw0szSieIw0Rq8yQ0Ek7GtLh43jG+ap\nvcSEesTD1p4mjPXoWkPG8KYd4iwGedZaePfheVcKKQKBgQDNE03SWv18AH0d4fpB\nlFAtRybCfSvMORzBrt2oilz8wDmK+Zga5o+phCnM8v3eJy1v8BvIQ9RvwQA2uVgZ\nr701wNMpVrTsMujk83oVRhimZLk6Hyw07wmMgEHX7+izkm2Lk4Lk7Zol3VRfnWG6\nmIcUk7xB1yAs3mudsfx0VO0QyQKBgQC5wfdqCLj2hZk4sMZu8Bth+BHKChGItmDk\nAW7aNt+gaPyoryOJoi2OUO8ud8EyuqXiuslSk2pPtjvLhCppkoq6V8kmPAUzaxFk\n4nDEAxT9Un8IJ0j2ebv+koQKsBWjssbVSjrZgIcYIDK1QblgbCp2FSE3ima+V8ip\nOdNjiatWJQKBgEX8lox5nRSanhh6rIuA8DPjmmi5ix7xRs0avm7seXuQppK1R6G2\nmcTCY/mb2+Pa/vi6uuCHtZJGDaqfal+pyCr2GZp8CtapMS4hocJs37C5ozUguld+\nVIXsp4voRkQybsw5lWxHYloVxNu0vEuQDlmJabAWmNZ3OcbhnUSeTyFxAoGAFtkZ\n0owCHChwoT11Gt4jsBgwL/avE27DWigm92Y6eWOQeDsalupAyjmAQenu9Itqrgml\ni6egMu/KSQ0Xnmas86CqmC5XwWxQ9mS31BRA96u2/ky+t7pfej+RSDNCZiEuPbvk\noy4g78G+GvdbktWbH20X6dn3K0Bm6RG4w4yCa5UCgYBs0zAVs0DZmM8SUZJA/HuQ\nN6a1vKKns7xKw5N3SmX1KbDhx5LSZXfbUo2+QktE7iRf9G2f1o0q8kz9l/4AGXi1\nKJNUHupWoaQzGNrzAb27TUtFA0ocMG8KnqxjANWox5oPJS9OU5tw5H5dxeI/Senc\nkYW6eCnRzPcmBqex6Vuw4w==\n-----END PRIVATE KEY-----\n" + }, user_data="", trusted_image_certificates=[], ) @@ -431,7 +436,7 @@ class Test_compute_instance_metadata_sensitive_data: private_v6="", networks={}, has_config_drive=False, - metadata={"admin_password": "secret123"}, + metadata={"admin_password": "Tr0ub4dor3xKq9vLmZ"}, user_data="", trusted_image_certificates=[], ), @@ -486,7 +491,7 @@ class Test_compute_instance_metadata_sensitive_data: metadata={ "environment": "production", "application": "web-app", - "db_password": "supersecret123", + "db_password": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "region": "us-east", }, user_data="", @@ -544,7 +549,7 @@ class Test_compute_instance_metadata_sensitive_data: has_config_drive=False, metadata={ "first_key": "safe_value", - "api_key": "sk-1234567890abcdef", + "api_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "third_key": "also_safe", }, user_data="", @@ -574,3 +579,128 @@ class Test_compute_instance_metadata_sensitive_data: # Verify the secret is correctly attributed to 'api_key' key (second in order) assert "in metadata key 'api_key'" in result[0].status_extended assert result[0].resource_id == "instance-8" + + def test_instance_verified_secret_escalates_to_critical(self): + """Test that a confirmed live secret escalates the finding to CRITICAL (FAIL).""" + compute_client = mock.MagicMock() + compute_client.audit_config = {"secrets_validate": True} + compute_client.instances = [ + ComputeInstance( + id="instance-verified", + name="Verified Secret", + status="ACTIVE", + flavor_id="flavor-1", + security_groups=["default"], + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + is_locked=False, + locked_reason="", + key_name="", + user_id="", + access_ipv4="", + access_ipv6="", + public_v4="", + public_v6="", + private_v4="", + private_v6="", + networks={}, + has_config_drive=False, + metadata={"api_key": "placeholder"}, + user_data="", + trusted_image_certificates=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_openstack_provider(), + ), + mock.patch( + "prowler.providers.openstack.services.compute.compute_instance_metadata_sensitive_data.compute_instance_metadata_sensitive_data.compute_client", + new=compute_client, + ), + mock.patch( + "prowler.providers.openstack.services.compute.compute_instance_metadata_sensitive_data.compute_instance_metadata_sensitive_data.detect_secrets_scan_batch", + return_value={ + 0: [ + { + "type": "JSON Web Token (base64url-encoded)", + "line_number": 2, + "filename": "data", + "hashed_secret": "x", + "is_verified": True, + } + ] + }, + ) as mock_scan, + ): + from prowler.providers.openstack.services.compute.compute_instance_metadata_sensitive_data.compute_instance_metadata_sensitive_data import ( + compute_instance_metadata_sensitive_data, + ) + + check = compute_instance_metadata_sensitive_data() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "confirmed to be live" in result[0].status_extended + assert mock_scan.call_args.kwargs.get("validate") is True + + def test_scan_failure_reports_manual(self): + from prowler.lib.utils.utils import SecretsScanError + + compute_client = mock.MagicMock() + compute_client.audit_config = {"secrets_ignore_patterns": []} + compute_client.instances = [ + ComputeInstance( + id="instance-scan-fail", + name="Scan Fail", + status="ACTIVE", + flavor_id="flavor-1", + security_groups=["default"], + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + is_locked=False, + locked_reason="", + key_name="", + user_id="", + access_ipv4="", + access_ipv6="", + public_v4="", + public_v6="", + private_v4="", + private_v6="", + networks={}, + has_config_drive=False, + metadata={"api_key": "placeholder"}, + user_data="", + trusted_image_certificates=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_openstack_provider(), + ), + mock.patch( + "prowler.providers.openstack.services.compute.compute_instance_metadata_sensitive_data.compute_instance_metadata_sensitive_data.compute_client", + new=compute_client, + ), + mock.patch( + "prowler.providers.openstack.services.compute.compute_instance_metadata_sensitive_data.compute_instance_metadata_sensitive_data.detect_secrets_scan_batch", + side_effect=SecretsScanError("Kingfisher exited with code 1"), + ), + ): + from prowler.providers.openstack.services.compute.compute_instance_metadata_sensitive_data.compute_instance_metadata_sensitive_data import ( + compute_instance_metadata_sensitive_data, + ) + + check = compute_instance_metadata_sensitive_data() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not scan" in result[0].status_extended diff --git a/tests/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data_test.py b/tests/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data_test.py index 6cae6432c7..eb92274232 100644 --- a/tests/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data_test.py +++ b/tests/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data_test.py @@ -2,6 +2,7 @@ from unittest import mock +from prowler.lib.check.models import Severity from prowler.providers.openstack.services.objectstorage.objectstorage_service import ( ObjectStorageContainer, ) @@ -157,7 +158,7 @@ class Test_objectstorage_container_metadata_sensitive_data: history_location="", sync_to="", sync_key="", - metadata={"db_password": "supersecret123"}, + metadata={"db_password": "Tr0ub4dor3xKq9vLmZ"}, ) ] @@ -217,7 +218,7 @@ class Test_objectstorage_container_metadata_sensitive_data: history_location="", sync_to="", sync_key="", - metadata={"admin_password": "secret123"}, + metadata={"admin_password": "Tr0ub4dor3xKq9vLmZ"}, ), ] @@ -241,3 +242,63 @@ class Test_objectstorage_container_metadata_sensitive_data: assert len(result) == 2 assert len([r for r in result if r.status == "PASS"]) == 1 assert len([r for r in result if r.status == "FAIL"]) == 1 + + def test_container_verified_secret_escalates_to_critical(self): + """Test that a confirmed live secret escalates the finding to CRITICAL (FAIL).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.audit_config = {"secrets_validate": True} + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-verified", + name="verified-secret", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=0, + bytes_used=0, + read_ACL="", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={"api_key": "placeholder"}, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_openstack_provider(), + ), + mock.patch( + "prowler.providers.openstack.services.objectstorage.objectstorage_container_metadata_sensitive_data.objectstorage_container_metadata_sensitive_data.objectstorage_client", + new=objectstorage_client, + ), + mock.patch( + "prowler.providers.openstack.services.objectstorage.objectstorage_container_metadata_sensitive_data.objectstorage_container_metadata_sensitive_data.detect_secrets_scan_batch", + return_value={ + 0: [ + { + "type": "JSON Web Token (base64url-encoded)", + "line_number": 2, + "filename": "data", + "hashed_secret": "x", + "is_verified": True, + } + ] + }, + ) as mock_scan, + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_metadata_sensitive_data.objectstorage_container_metadata_sensitive_data import ( + objectstorage_container_metadata_sensitive_data, + ) + + check = objectstorage_container_metadata_sensitive_data() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "confirmed to be live" in result[0].status_extended + assert mock_scan.call_args.kwargs.get("validate") is True diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 7c06cc56b1..898d70dde4 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -40,6 +40,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | 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` | +| Reviewing Prowler UI components | `prowler-ui` | | Testing hooks or utilities | `vitest` | | Update CHANGELOG.md in any component | `prowler-changelog` | | Using Zustand stores | `zustand-5` | diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 7d76c8a27f..7a0a7de47a 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.32.0] (Prowler v5.32.0) + +### 🚀 Added + +- Filter the Overview, Findings, Resources, Scans, and Providers views by provider group [(#11659)](https://github.com/prowler-cloud/prowler/pull/11659) +- CIS Controls v8.1 compliance support, including its detail view and report mapping [(#11700)](https://github.com/prowler-cloud/prowler/pull/11700) + +--- + ## [1.31.1] (Prowler v5.31.1) ### 🔄 Changed diff --git a/ui/actions/finding-groups/finding-groups.adapter.test.ts b/ui/actions/finding-groups/finding-groups.adapter.test.ts index c9b4a0314c..e4dcb66804 100644 --- a/ui/actions/finding-groups/finding-groups.adapter.test.ts +++ b/ui/actions/finding-groups/finding-groups.adapter.test.ts @@ -1,10 +1,26 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + FINDING_TRIAGE_DISABLED_REASON, + FINDING_TRIAGE_STATUS, +} from "@/types/findings-triage"; import { adaptFindingGroupResourcesResponse, adaptFindingGroupsResponse, } from "./finding-groups.adapter"; +const expectNoRawTriageTransportKeys = (value: Record) => { + expect(value).not.toHaveProperty("triage_status"); + expect(value).not.toHaveProperty("triage_has_note"); + expect(value).not.toHaveProperty("attributes"); + expect(value).not.toHaveProperty("relationships"); +}; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + // --------------------------------------------------------------------------- // Fix 1: adaptFindingGroupsResponse — unknown + type guard // --------------------------------------------------------------------------- @@ -21,17 +37,6 @@ describe("adaptFindingGroupsResponse — malformed input", () => { expect(result).toEqual([]); }); - it("should return [] when apiResponse has no data property", () => { - // Given - const input = { meta: { total: 0 } }; - - // When - const result = adaptFindingGroupsResponse(input); - - // Then - expect(result).toEqual([]); - }); - it("should return [] when data is not an array", () => { // Given const input = { data: "not-an-array" }; @@ -43,28 +48,6 @@ describe("adaptFindingGroupsResponse — malformed input", () => { expect(result).toEqual([]); }); - it("should return [] when data is null", () => { - // Given - const input = { data: null }; - - // When - const result = adaptFindingGroupsResponse(input); - - // Then - expect(result).toEqual([]); - }); - - it("should return [] when apiResponse is undefined", () => { - // Given - const input = undefined; - - // When - const result = adaptFindingGroupsResponse(input); - - // Then - expect(result).toEqual([]); - }); - it("should return mapped rows for valid data", () => { // Given const input = { @@ -106,6 +89,10 @@ describe("adaptFindingGroupsResponse — malformed input", () => { first_seen_at: null, last_seen_at: "2024-01-01T00:00:00Z", failing_since: null, + finding_id: "group-finding-id-1", + finding_uid: "group-finding-uid-1", + triage_status: "risk_accepted", + triage_has_note: true, }, }, ], @@ -121,6 +108,10 @@ describe("adaptFindingGroupsResponse — malformed input", () => { expect(result[0].muted).toBe(true); expect(result[0].manualCount).toBe(1); expect(result[0].newFailMutedCount).toBe(1); + expect(result[0]).not.toHaveProperty("triage"); + expectNoRawTriageTransportKeys( + result[0] as unknown as Record, + ); }); }); @@ -137,14 +128,6 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => { expect(result).toEqual([]); }); - it("should return [] when apiResponse has no data property", () => { - // Given/When - const result = adaptFindingGroupResourcesResponse({ meta: {} }, "check-1"); - - // Then - expect(result).toEqual([]); - }); - it("should return [] when data is not an array", () => { // Given/When const result = adaptFindingGroupResourcesResponse({ data: {} }, "check-1"); @@ -153,12 +136,206 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => { expect(result).toEqual([]); }); - it("should return [] when apiResponse is undefined", () => { - // Given/When - const result = adaptFindingGroupResourcesResponse(undefined, "check-1"); + it("should keep resource rows with valid attributes even when JSON:API type differs", () => { + // Given + const input = { + data: [ + { + id: "resource-row-1", + type: "findings", + attributes: { + finding_id: "real-finding-uuid", + finding_uid: "prowler-finding-uid-1", + triage_status: "remediating", + triage_notes_count: 1, + resource: { + uid: "arn:aws:s3:::my-bucket", + name: "my-bucket", + service: "s3", + region: "us-east-1", + type: "Bucket", + resource_group: "default", + }, + provider: { + type: "aws", + uid: "123456789", + alias: "production", + }, + status: "FAIL", + severity: "high", + first_seen_at: null, + last_seen_at: "2024-01-01T00:00:00Z", + }, + }, + ], + }; + + // When + const result = adaptFindingGroupResourcesResponse(input, "check-1"); // Then - expect(result).toEqual([]); + expect(result).toHaveLength(1); + expect(result[0]).toEqual( + expect.objectContaining({ + findingId: "real-finding-uuid", + resourceName: "my-bucket", + triage: expect.objectContaining({ + status: FINDING_TRIAGE_STATUS.REMEDIATING, + hasVisibleNote: true, + }), + }), + ); + }); + + it("should skip malformed resource entries inside a data array", () => { + // Given + const input = { + data: [ + null, + "bad-entry", + { + id: "resource-row-1", + type: "finding-group-resources", + attributes: { + finding_id: "real-finding-uuid", + resource: { + uid: "arn:aws:s3:::my-bucket", + name: "my-bucket", + service: "s3", + region: "us-east-1", + type: "Bucket", + resource_group: "default", + }, + provider: { + type: "aws", + uid: "123456789", + alias: "production", + }, + status: "FAIL", + severity: "high", + first_seen_at: null, + last_seen_at: "2024-01-01T00:00:00Z", + }, + }, + ], + }; + + // When + const result = adaptFindingGroupResourcesResponse(input, "check-1"); + + // Then + expect(result).toHaveLength(1); + expect(result[0]).toEqual( + expect.objectContaining({ + findingId: "real-finding-uuid", + resourceName: "my-bucket", + }), + ); + }); + + it("should attach adapter-produced triage DTOs to finding-level resource rows", () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + const input = { + data: [ + { + id: "resource-row-1", + type: "finding-group-resources", + attributes: { + finding_id: "real-finding-uuid", + finding_uid: "prowler-finding-uid-1", + triage_status: "under_review", + triage_has_note: true, + resource: { + uid: "arn:aws:s3:::my-bucket", + name: "my-bucket", + service: "s3", + region: "us-east-1", + type: "Bucket", + resource_group: "default", + }, + provider: { + type: "aws", + uid: "123456789", + alias: "production", + }, + status: "FAIL", + muted: false, + delta: "new", + severity: "critical", + first_seen_at: null, + last_seen_at: "2024-01-01T00:00:00Z", + }, + }, + ], + }; + + // When + const [row] = adaptFindingGroupResourcesResponse(input, "s3_check"); + + // Then + expect(row.triage).toEqual( + expect.objectContaining({ + findingId: "real-finding-uuid", + findingUid: "prowler-finding-uid-1", + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + label: "Under Review", + hasVisibleNote: true, + canEdit: true, + }), + ); + expectNoRawTriageTransportKeys( + row.triage as unknown as Record, + ); + }); + + it("should leave triage editing disabled until a real capability is provided", () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + const input = { + data: [ + { + id: "resource-row-1", + type: "finding-group-resources", + attributes: { + finding_id: "real-finding-uuid", + finding_uid: "prowler-finding-uid-1", + triage_status: "open", + triage_has_note: false, + resource: { + uid: "arn:aws:s3:::my-bucket", + name: "my-bucket", + service: "s3", + region: "us-east-1", + type: "Bucket", + resource_group: "default", + }, + provider: { + type: "aws", + uid: "123456789", + alias: "production", + }, + status: "FAIL", + muted: false, + delta: "new", + severity: "critical", + first_seen_at: null, + last_seen_at: "2024-01-01T00:00:00Z", + }, + }, + ], + }; + + // When + const [row] = adaptFindingGroupResourcesResponse(input, "s3_check"); + + // Then + expect(row.triage).toEqual( + expect.objectContaining({ + canEdit: false, + disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY, + }), + ); }); it("should return mapped rows for valid data", () => { diff --git a/ui/actions/finding-groups/finding-groups.adapter.ts b/ui/actions/finding-groups/finding-groups.adapter.ts index 6b78bc189a..e8d42beaa5 100644 --- a/ui/actions/finding-groups/finding-groups.adapter.ts +++ b/ui/actions/finding-groups/finding-groups.adapter.ts @@ -1,3 +1,5 @@ +import { adaptFindingTriageSummariesResponse } from "@/actions/findings/findings-triage.adapter"; +import { getFindingTriageAdapterOptions } from "@/actions/findings/findings-triage.options"; import type { FindingGroupRow, FindingResourceRow, @@ -72,6 +74,7 @@ export function adaptFindingGroupsResponse( } const data = (apiResponse as { data: FindingGroupApiItem[] }).data; + return data.map((item) => ({ id: item.id, rowType: FINDINGS_ROW_TYPE.GROUP, @@ -146,6 +149,9 @@ interface FindingGroupResourceAttributes { first_seen_at: string | null; last_seen_at: string | null; muted_reason?: string | null; + finding_uid?: string; + triage_status?: string; + triage_has_note?: boolean; } interface FindingGroupResourceApiItem { @@ -154,6 +160,26 @@ interface FindingGroupResourceApiItem { attributes: FindingGroupResourceAttributes; } +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object"; + +const isFindingGroupResourceApiItem = ( + value: unknown, +): value is FindingGroupResourceApiItem => { + if (!isRecord(value) || typeof value.id !== "string") { + return false; + } + + const attributes = value.attributes; + + return ( + isRecord(attributes) && + typeof attributes.finding_id === "string" && + isRecord(attributes.resource) && + isRecord(attributes.provider) + ); +}; + /** * Transforms the API response for finding group resources (drill-down) * into FindingResourceRow[]. @@ -171,8 +197,15 @@ export function adaptFindingGroupResourcesResponse( return []; } - const data = (apiResponse as { data: FindingGroupResourceApiItem[] }).data; - return data.map((item) => ({ + const data = (apiResponse as { data: unknown[] }).data.filter( + isFindingGroupResourceApiItem, + ); + const triageSummaries = adaptFindingTriageSummariesResponse( + { ...apiResponse, data }, + getFindingTriageAdapterOptions(), + ); + + return data.map((item, index) => ({ id: item.id, rowType: FINDINGS_ROW_TYPE.RESOURCE, findingId: item.attributes.finding_id || item.id, @@ -194,5 +227,6 @@ export function adaptFindingGroupResourcesResponse( mutedReason: item.attributes.muted_reason || undefined, firstSeenAt: item.attributes.first_seen_at, lastSeenAt: item.attributes.last_seen_at, + triage: triageSummaries[index], })); } diff --git a/ui/actions/finding-groups/finding-groups.ts b/ui/actions/finding-groups/finding-groups.ts index bf5df80aae..faa697cf32 100644 --- a/ui/actions/finding-groups/finding-groups.ts +++ b/ui/actions/finding-groups/finding-groups.ts @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; +import type { FindingsFilterParam } from "@/actions/findings/findings-filters"; import { apiBaseUrl, composeSort, @@ -15,7 +16,6 @@ import { } from "@/lib"; import { appendSanitizedProviderFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; -import { FilterParam } from "@/types/filters"; /** * Maps filter[search] to filter[check_title__icontains] for finding-groups. @@ -39,7 +39,7 @@ function mapSearchFilter( * finding-group resources sub-endpoint. These must be stripped before * calling the resources API to avoid empty results. */ -const FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS: FilterParam[] = [ +const FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS: FindingsFilterParam[] = [ "filter[service__in]", "filter[scan__in]", "filter[scan_id]", @@ -53,7 +53,7 @@ function normalizeFindingGroupResourceFilters( Object.entries(filters).filter( ([key]) => !FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS.includes( - key as FilterParam, + key as FindingsFilterParam, ), ), ); diff --git a/ui/actions/findings/findings-by-resource.adapter.test.ts b/ui/actions/findings/findings-by-resource.adapter.test.ts index a94f34a9c4..24be4cd6ed 100644 --- a/ui/actions/findings/findings-by-resource.adapter.test.ts +++ b/ui/actions/findings/findings-by-resource.adapter.test.ts @@ -22,6 +22,8 @@ vi.mock("next/navigation", () => ({ // Import after mocks // --------------------------------------------------------------------------- +import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage"; + import { adaptFindingsByResourceResponse } from "./findings-by-resource.adapter"; // --------------------------------------------------------------------------- @@ -43,22 +45,6 @@ describe("adaptFindingsByResourceResponse — malformed input", () => { expect(result).toEqual([]); }); - it("should return [] when apiResponse is undefined", () => { - // Given/When - const result = adaptFindingsByResourceResponse(undefined); - - // Then - expect(result).toEqual([]); - }); - - it("should return [] when apiResponse has no data property", () => { - // Given/When - const result = adaptFindingsByResourceResponse({ meta: {} }); - - // Then - expect(result).toEqual([]); - }); - it("should return [] when data is not an array", () => { // Given/When const result = adaptFindingsByResourceResponse({ data: "bad" }); @@ -75,14 +61,6 @@ describe("adaptFindingsByResourceResponse — malformed input", () => { expect(result).toEqual([]); }); - it("should return [] when data is a number", () => { - // Given/When - const result = adaptFindingsByResourceResponse({ data: 42 }); - - // Then - expect(result).toEqual([]); - }); - it("should return mapped findings for valid minimal data", () => { // Given — minimal valid JSON:API shape const input = { @@ -194,8 +172,8 @@ describe("adaptFindingsByResourceResponse — malformed input", () => { expect(result[0].resourceMetadata).toBeNull(); }); - it("should normalize a single finding response into a one-item drawer array", () => { - // Given — getFindingById returns a single JSON:API resource object + it("should preserve triage summary fields for a single finding response", () => { + // Given - getFindingById returns a single finding with provisional triage fields const input = { data: { id: "finding-1", @@ -204,6 +182,10 @@ describe("adaptFindingsByResourceResponse — malformed input", () => { check_id: "s3_check", status: "FAIL", severity: "critical", + triage_id: "triage-1", + triage_status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + triage_notes_count: 1, + triage_has_note: true, check_metadata: { checktitle: "S3 Check", }, @@ -220,8 +202,16 @@ describe("adaptFindingsByResourceResponse — malformed input", () => { const result = adaptFindingsByResourceResponse(input); // Then - expect(result).toHaveLength(1); - expect(result[0].id).toBe("finding-1"); - expect(result[0].checkTitle).toBe("S3 Check"); + expect(result[0].triage).toEqual( + expect.objectContaining({ + findingId: "finding-1", + findingUid: "uid-1", + triageId: "triage-1", + notesCount: 1, + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + label: "Under Review", + hasVisibleNote: true, + }), + ); }); }); diff --git a/ui/actions/findings/findings-by-resource.adapter.ts b/ui/actions/findings/findings-by-resource.adapter.ts index 0312df00da..3ebae3c6c5 100644 --- a/ui/actions/findings/findings-by-resource.adapter.ts +++ b/ui/actions/findings/findings-by-resource.adapter.ts @@ -1,5 +1,8 @@ +import { adaptFindingTriageSummariesResponse } from "@/actions/findings/findings-triage.adapter"; +import { getFindingTriageAdapterOptions } from "@/actions/findings/findings-triage.options"; import { createDict } from "@/lib"; import type { ProviderType, Severity } from "@/types"; +import type { FindingTriageSummary } from "@/types/findings-triage"; export interface RemediationRecommendation { text: string; @@ -49,6 +52,7 @@ export interface ResourceDrawerFinding { mutedReason: string | null; firstSeenAt: string | null; updatedAt: string | null; + triage?: FindingTriageSummary; // Resource resourceId: string; resourceUid: string; @@ -195,8 +199,12 @@ export function adaptFindingsByResourceResponse( const findings = Array.isArray(apiResponse.data) ? apiResponse.data : [apiResponse.data]; + const triageSummaries = adaptFindingTriageSummariesResponse( + { ...apiResponse, data: findings }, + getFindingTriageAdapterOptions(), + ); - return findings.map((item) => { + return findings.map((item, index) => { const attrs = item.attributes; const meta = (attrs.check_metadata || {}) as Record; const remediationRaw = meta.remediation as @@ -254,6 +262,7 @@ export function adaptFindingsByResourceResponse( mutedReason: attrs.muted_reason || null, firstSeenAt: attrs.first_seen_at || null, updatedAt: attrs.updated_at || null, + triage: triageSummaries[index], // Resource resourceId: resourceRel?.id || "", resourceUid: (resourceAttrs.uid as string | undefined) || "-", diff --git a/ui/actions/findings/findings-filters.ts b/ui/actions/findings/findings-filters.ts new file mode 100644 index 0000000000..268d71e7cd --- /dev/null +++ b/ui/actions/findings/findings-filters.ts @@ -0,0 +1,40 @@ +import { FILTER_FIELD, FilterParam } from "@/types/filters"; + +/** Findings-only filter fields not shared with other views. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const FINDINGS_EXTRA_FIELD = { + DELTA_IN: "delta__in", + SCAN_EXACT: "scan", + SCAN_ID: "scan_id", + SCAN_ID_IN: "scan_id__in", + INSERTED_AT: "inserted_at", + INSERTED_AT_GTE: "inserted_at__gte", + INSERTED_AT_LTE: "inserted_at__lte", + MUTED: "muted", +} as const; + +type FindingsExtraField = + (typeof FINDINGS_EXTRA_FIELD)[keyof typeof FINDINGS_EXTRA_FIELD]; + +/** + * URL filter param keys the findings view supports, e.g. `filter[severity__in]`. + * Composed from the shared fields it uses plus a few findings-only extras + * (alternate scan/date/delta forms not used by other views). + */ +export type FindingsFilterParam = FilterParam< + // findings uses provider_id, not provider_uid + | (typeof FILTER_FIELD)[ + | "PROVIDER_TYPE" + | "PROVIDER_ID" + | "PROVIDER_GROUPS" + | "REGION" + | "SERVICE" + | "SEVERITY" + | "STATUS" + | "DELTA" + | "RESOURCE_TYPE" + | "CATEGORY" + | "RESOURCE_GROUPS" + | "SCAN"] + | FindingsExtraField +>; diff --git a/ui/actions/findings/findings-triage.adapter.test.ts b/ui/actions/findings/findings-triage.adapter.test.ts new file mode 100644 index 0000000000..c84500677c --- /dev/null +++ b/ui/actions/findings/findings-triage.adapter.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, it } from "vitest"; + +import { + FINDING_TRIAGE_DISABLED_REASON, + FINDING_TRIAGE_STATUS, + FINDING_TRIAGE_STATUS_LABELS, +} from "@/types/findings-triage"; + +import { + adaptFindingTriageDetailResponse, + adaptFindingTriageSummariesResponse, + adaptLatestFindingTriageNote, + attachFindingTriageSummariesToResponse, +} from "./findings-triage.adapter"; +import { + allProvisionalTriageStatusFindings, + findingTriageDetailResponse, + flatFindingWithAcceptedRiskTriage, + flatFindingWithNotePresenceOnly, + flatFindingWithUnderReviewTriage, + flatPassFindingWithoutPersistedTriage, +} from "./findings-triage.fixtures"; + +const expectNoRawTransportKeys = (value: Record) => { + expect(value).not.toHaveProperty("attributes"); + expect(value).not.toHaveProperty("relationships"); + expect(value).not.toHaveProperty("included"); + expect(value).not.toHaveProperty("triage_status"); + expect(value).not.toHaveProperty("triage_has_note"); + expect(value).not.toHaveProperty("triage_note"); + expect(value).not.toHaveProperty("current_note"); + expect(value).not.toHaveProperty("source"); + expect(value).not.toHaveProperty("updated_by"); + expect(value).not.toHaveProperty("inserted_at"); + expect(value).not.toHaveProperty("updated_at"); +}; + +describe("provisional findings triage contract fixtures", () => { + it("should document every triage status the provisional contract can return", () => { + // Given + const input = { + data: allProvisionalTriageStatusFindings, + }; + + // When + const result = adaptFindingTriageSummariesResponse(input, { + canEdit: true, + }); + + // Then + expect(result).toHaveLength(7); + expect(result.map((summary) => summary.status)).toEqual([ + FINDING_TRIAGE_STATUS.OPEN, + FINDING_TRIAGE_STATUS.UNDER_REVIEW, + FINDING_TRIAGE_STATUS.REMEDIATING, + FINDING_TRIAGE_STATUS.RESOLVED, + FINDING_TRIAGE_STATUS.RISK_ACCEPTED, + FINDING_TRIAGE_STATUS.FALSE_POSITIVE, + FINDING_TRIAGE_STATUS.REOPENED, + ]); + expect(result.map((summary) => summary.label)).toEqual([ + "Open", + "Under Review", + "Remediating", + "Resolved", + "Risk Accepted", + "False Positive", + "Reopened", + ]); + expect(result[0].label).toBe( + FINDING_TRIAGE_STATUS_LABELS[FINDING_TRIAGE_STATUS.OPEN], + ); + }); + + it("should model table note presence without requiring note previews", () => { + // Given + const input = { + data: [flatFindingWithNotePresenceOnly], + }; + + // When + const [summary] = adaptFindingTriageSummariesResponse(input); + + // Then + expect(summary).toEqual( + expect.objectContaining({ + findingId: "finding-note-presence-1", + findingUid: "prowler-finding-note-presence-uid-1", + hasVisibleNote: true, + }), + ); + expect(JSON.stringify(summary)).not.toContain("triage_note"); + expect(JSON.stringify(summary)).not.toContain("current_note"); + }); + + it("should model modal note detail as a separate detail payload", () => { + // Given / When + const detail = adaptFindingTriageDetailResponse( + findingTriageDetailResponse, + ); + + // Then + expect(detail.noteBody).toBe("Current note visible only inside the modal."); + expect(detail.hasVisibleNote).toBe(true); + expect(detail.maxNoteLength).toBe(500); + }); + + it("should model disabled non-paying state through adapter options only", () => { + // Given + const input = { + data: [flatFindingWithUnderReviewTriage], + }; + + // When + const [summary] = adaptFindingTriageSummariesResponse(input, { + canEdit: false, + disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY, + }); + + // Then + expect(summary.canEdit).toBe(false); + expect(summary.disabledReason).toBe( + FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY, + ); + expect(summary.billingHref).toBe("https://prowler.com/pricing"); + }); +}); + +describe("adaptLatestFindingTriageNote", () => { + it("should adapt the newest note from a JSON:API collection", () => { + // Given + const response = { + data: [ + { + id: "note-latest", + type: "finding-triage-notes", + attributes: { + body: "Latest investigation note", + }, + }, + ], + }; + + // When + const result = adaptLatestFindingTriageNote(response); + + // Then + expect(result).toEqual({ + noteId: "note-latest", + noteBody: "Latest investigation note", + }); + }); + + it("should return null when the response has no usable note", () => { + expect(adaptLatestFindingTriageNote({ data: [] })).toBeNull(); + expect( + adaptLatestFindingTriageNote({ data: [{ id: "note-1" }] }), + ).toBeNull(); + }); +}); + +describe("adaptFindingTriageSummariesResponse", () => { + it("should return [] when the provisional API response is malformed", () => { + // Given + const input = { meta: { count: 0 } }; + + // When + const result = adaptFindingTriageSummariesResponse(input); + + // Then + expect(result).toEqual([]); + }); + + it("should skip malformed entries inside a data array", () => { + // Given + const input = { + data: [null, "bad-entry", flatFindingWithUnderReviewTriage], + }; + + // When + const result = adaptFindingTriageSummariesResponse(input); + + // Then + expect(result).toHaveLength(1); + expect(result[0]).toEqual( + expect.objectContaining({ + findingId: "finding-1", + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + }), + ); + }); + + it("should not shift attached summaries after malformed entries", () => { + // Given + const validWithoutTriage = { + id: "resource-row-1", + type: "finding-group-resources", + attributes: { + finding_id: "finding-1", + finding_uid: "prowler-finding-uid-1", + triage_status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + triage_notes_count: 1, + status: "FAIL", + }, + }; + const laterValidWithoutTriage = { + id: "resource-row-2", + type: "finding-group-resources", + attributes: { + finding_id: "finding-2", + finding_uid: "prowler-finding-uid-2", + triage_status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED, + triage_notes_count: 0, + status: "MUTED", + }, + }; + const input = { + data: [validWithoutTriage, null, laterValidWithoutTriage], + }; + + // When + const result = attachFindingTriageSummariesToResponse(input); + + // Then + expect(result?.data[0]).toEqual( + expect.objectContaining({ + triage: expect.objectContaining({ + findingId: "finding-1", + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + }), + }), + ); + expect(result?.data[1]).toBeNull(); + expect(result?.data[2]).toEqual( + expect.objectContaining({ + triage: expect.objectContaining({ + findingId: "finding-2", + status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED, + }), + }), + ); + }); + + it("should use triage_notes_count from the resource triage API contract", () => { + // Given + const input = { + data: [ + { + id: "resource-row-1", + type: "finding-group-resources", + attributes: { + finding_id: "finding-1", + finding_uid: "prowler-finding-uid-1", + triage_status: FINDING_TRIAGE_STATUS.REMEDIATING, + triage_notes_count: 5, + status: "FAIL", + }, + }, + { + id: "resource-row-2", + type: "finding-group-resources", + attributes: { + finding_id: "finding-2", + finding_uid: "prowler-finding-uid-2", + triage_status: FINDING_TRIAGE_STATUS.OPEN, + triage_notes_count: 0, + status: "FAIL", + }, + }, + ], + }; + + // When + const result = adaptFindingTriageSummariesResponse(input); + + // Then + expect(result).toHaveLength(2); + expect(result[0]).toEqual( + expect.objectContaining({ + hasVisibleNote: true, + status: FINDING_TRIAGE_STATUS.REMEDIATING, + }), + ); + expect(result[1]).toEqual( + expect.objectContaining({ + hasVisibleNote: false, + status: FINDING_TRIAGE_STATUS.OPEN, + }), + ); + }); + + it("should mark triage as muted when resource status is MUTED", () => { + // Given + const input = { + data: [ + { + id: "resource-row-muted-1", + type: "finding-group-resources", + attributes: { + finding_id: "finding-muted-1", + finding_uid: "prowler-finding-muted-uid-1", + triage_status: FINDING_TRIAGE_STATUS.OPEN, + triage_notes_count: 0, + status: "MUTED", + }, + }, + ], + }; + + // When + const [summary] = adaptFindingTriageSummariesResponse(input); + + // Then + expect(summary).toEqual( + expect.objectContaining({ + findingId: "finding-muted-1", + isMuted: true, + status: FINDING_TRIAGE_STATUS.OPEN, + }), + ); + }); + + it("should normalize flat provisional finding fields into domain triage summaries", () => { + // Given + const input = { + data: [flatFindingWithUnderReviewTriage], + }; + + // When + const result = adaptFindingTriageSummariesResponse(input, { + canEdit: true, + }); + + // Then + expect(result).toEqual([ + expect.objectContaining({ + findingId: "finding-1", + findingUid: "prowler-finding-uid-1", + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + label: "Under Review", + hasVisibleNote: true, + canEdit: true, + billingHref: "https://prowler.com/pricing", + }), + ]); + }); + + it("should fallback from scan status when no persisted triage status exists", () => { + // Given + const input = { + data: [flatPassFindingWithoutPersistedTriage], + }; + + // When + const result = adaptFindingTriageSummariesResponse(input); + + // Then + expect(result[0]).toEqual( + expect.objectContaining({ + status: FINDING_TRIAGE_STATUS.RESOLVED, + label: "Resolved", + hasVisibleNote: false, + }), + ); + }); + + it("should keep raw provisional fields out of table component DTOs", () => { + // Given + const input = { + data: [flatFindingWithAcceptedRiskTriage], + }; + + // When + const [summary] = adaptFindingTriageSummariesResponse(input); + + // Then + expectNoRawTransportKeys(summary as unknown as Record); + expect(JSON.stringify(summary)).not.toContain("Accepted risk note body"); + }); +}); + +describe("adaptFindingTriageDetailResponse", () => { + it("should normalize provisional detail payloads into modal DTOs", () => { + // Given + const input = findingTriageDetailResponse; + + // When + const detail = adaptFindingTriageDetailResponse(input, { + canEdit: true, + }); + + // Then + expect(detail).toEqual( + expect.objectContaining({ + findingId: "finding-1", + findingUid: "prowler-finding-uid-1", + status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED, + label: "Risk Accepted", + hasVisibleNote: true, + canEdit: true, + noteBody: "Current note visible only inside the modal.", + maxNoteLength: 500, + }), + ); + }); + + it("should keep raw provisional fields out of modal component DTOs", () => { + // Given + const input = findingTriageDetailResponse; + + // When + const detail = adaptFindingTriageDetailResponse(input); + + // Then + expectNoRawTransportKeys(detail as unknown as Record); + }); +}); diff --git a/ui/actions/findings/findings-triage.adapter.ts b/ui/actions/findings/findings-triage.adapter.ts new file mode 100644 index 0000000000..2ddfec94e9 --- /dev/null +++ b/ui/actions/findings/findings-triage.adapter.ts @@ -0,0 +1,218 @@ +import { + FINDING_TRIAGE_BILLING_HREF, + FINDING_TRIAGE_NOTE_MAX_LENGTH, + FINDING_TRIAGE_STATUS, + FINDING_TRIAGE_STATUS_LABELS, + type FindingTriageDetail, + type FindingTriageDisabledReason, + type FindingTriageLoadedNote, + type FindingTriageStatus, + type FindingTriageSummary, +} from "@/types/findings-triage"; + +// API/backend triage implementation is external to this UI slice. Keep final +// contract churn isolated here (and the server action transport) so table/modal +// components continue consuming stable domain DTOs. +interface FindingTriageAdapterOptions { + canEdit?: boolean; + disabledReason?: FindingTriageDisabledReason; + billingHref?: string; +} + +interface FindingTriageAttributes { + finding_id?: string; + finding_uid?: string; + uid?: string; + triage_id?: string; + triage_notes_count?: number; + triage_status?: unknown; + triage_has_note?: boolean; + status?: unknown; + muted?: boolean; + body?: unknown; + current_note?: string; + note?: string; + has_note?: boolean; + note_id?: string; +} + +interface JsonApiResource { + id?: string; + attributes?: FindingTriageAttributes; +} + +interface NormalizedTriageFields { + status: FindingTriageStatus; + hasVisibleNote: boolean; +} + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object"; + +const isJsonApiResource = (value: unknown): value is JsonApiResource => + isRecord(value) && + (!("attributes" in value) || + value.attributes === undefined || + isRecord(value.attributes)); + +const isFindingTriageStatus = (value: unknown): value is FindingTriageStatus => + typeof value === "string" && + Object.values(FINDING_TRIAGE_STATUS).includes(value as FindingTriageStatus); + +const fallbackStatusFromFindingStatus = ( + findingStatus: unknown, +): FindingTriageStatus => + findingStatus === "PASS" + ? FINDING_TRIAGE_STATUS.RESOLVED + : FINDING_TRIAGE_STATUS.OPEN; + +const normalizeTriageFields = ( + finding: JsonApiResource, +): NormalizedTriageFields => { + const attributes = finding.attributes ?? {}; + + if (isFindingTriageStatus(attributes.triage_status)) { + return { + status: attributes.triage_status, + hasVisibleNote: + (typeof attributes.triage_notes_count === "number" && + attributes.triage_notes_count >= 1) || + attributes.triage_has_note === true, + }; + } + + return { + status: fallbackStatusFromFindingStatus(attributes.status), + hasVisibleNote: false, + }; +}; + +const createSummary = ( + finding: JsonApiResource, + triageFields: NormalizedTriageFields, + options: FindingTriageAdapterOptions, +): FindingTriageSummary => { + const attributes = finding.attributes ?? {}; + const summary: FindingTriageSummary = { + findingId: attributes.finding_id || finding.id || "", + findingUid: attributes.uid || attributes.finding_uid || "", + triageId: attributes.triage_id || null, + notesCount: attributes.triage_notes_count ?? 0, + status: triageFields.status, + label: FINDING_TRIAGE_STATUS_LABELS[triageFields.status], + hasVisibleNote: triageFields.hasVisibleNote, + isMuted: + typeof attributes.muted === "boolean" + ? attributes.muted + : attributes.status === "MUTED", + canEdit: options.canEdit ?? false, + billingHref: options.billingHref ?? FINDING_TRIAGE_BILLING_HREF, + }; + + if (options.disabledReason) { + summary.disabledReason = options.disabledReason; + } + + return summary; +}; + +export function adaptFindingTriageSummariesResponse( + apiResponse: unknown, + options: FindingTriageAdapterOptions = {}, +): FindingTriageSummary[] { + if (!isRecord(apiResponse) || !Array.isArray(apiResponse.data)) { + return []; + } + + return apiResponse.data + .filter(isJsonApiResource) + .map((finding) => + createSummary(finding, normalizeTriageFields(finding), options), + ); +} + +export function adaptLatestFindingTriageNote( + apiResponse: unknown, +): FindingTriageLoadedNote | null { + const latestNote = + isRecord(apiResponse) && Array.isArray(apiResponse.data) + ? apiResponse.data.find(isJsonApiResource) + : undefined; + const noteId = latestNote?.id; + const noteBody = latestNote?.attributes?.body; + + if (typeof noteId !== "string" || !noteId || typeof noteBody !== "string") { + return null; + } + + return { + noteId, + noteBody, + }; +} + +export function attachFindingTriageSummariesToResponse< + T extends { data?: unknown }, +>( + apiResponse: T | undefined, + options: FindingTriageAdapterOptions = {}, +): T | undefined { + if (!apiResponse || !Array.isArray(apiResponse.data)) { + return apiResponse; + } + + return { + ...apiResponse, + data: apiResponse.data.map((item) => + isJsonApiResource(item) + ? { + ...item, + triage: createSummary(item, normalizeTriageFields(item), options), + } + : item, + ), + }; +} + +export function adaptFindingTriageDetailResponse( + apiResponse: unknown, + options: FindingTriageAdapterOptions = {}, +): FindingTriageDetail { + const data = + isRecord(apiResponse) && isJsonApiResource(apiResponse.data) + ? apiResponse.data + : undefined; + const attributes = data?.attributes ?? {}; + const status = isFindingTriageStatus(attributes.status) + ? attributes.status + : FINDING_TRIAGE_STATUS.OPEN; + const noteBody = + typeof attributes.current_note === "string" + ? attributes.current_note + : typeof attributes.note === "string" + ? attributes.note + : ""; + const summary = createSummary( + { + id: attributes.finding_id || data?.id || "", + attributes: { + finding_uid: attributes.finding_uid || "", + triage_id: data?.id, + triage_notes_count: + attributes.triage_notes_count ?? (noteBody.length > 0 ? 1 : 0), + }, + }, + { + status, + hasVisibleNote: attributes.has_note === true || noteBody.length > 0, + }, + options, + ); + + return { + ...summary, + noteId: attributes.note_id || null, + noteBody, + maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH, + }; +} diff --git a/ui/actions/findings/findings-triage.fixtures.ts b/ui/actions/findings/findings-triage.fixtures.ts new file mode 100644 index 0000000000..879564ed8b --- /dev/null +++ b/ui/actions/findings/findings-triage.fixtures.ts @@ -0,0 +1,101 @@ +// Provisional UI/API contract fixtures only. API implementation is external to this +// UI slice; when the final API lands, update the adapter/server-action seam instead +// of teaching table or modal components about the transport payload shape. +const createFlatFindingWithTriageStatus = (status: string, index: number) => ({ + type: "findings", + id: `finding-contract-${index}`, + attributes: { + uid: `prowler-finding-contract-uid-${index}`, + status: "FAIL", + triage_status: status, + triage_has_note: false, + }, +}); + +export const allProvisionalTriageStatusFindings = [ + createFlatFindingWithTriageStatus("open", 1), + createFlatFindingWithTriageStatus("under_review", 2), + createFlatFindingWithTriageStatus("remediating", 3), + createFlatFindingWithTriageStatus("resolved", 4), + createFlatFindingWithTriageStatus("risk_accepted", 5), + createFlatFindingWithTriageStatus("false_positive", 6), + createFlatFindingWithTriageStatus("reopened", 7), +] as const; + +export const flatFindingWithNotePresenceOnly = { + type: "findings", + id: "finding-note-presence-1", + attributes: { + uid: "prowler-finding-note-presence-uid-1", + status: "FAIL", + triage_id: "triage-note-presence-1", + triage_status: "under_review", + triage_notes_count: 1, + triage_has_note: true, + }, +} as const; + +export const flatFindingWithUnderReviewTriage = { + type: "findings", + id: "finding-1", + attributes: { + uid: "prowler-finding-uid-1", + status: "FAIL", + triage_id: "triage-note-presence-1", + triage_status: "under_review", + triage_notes_count: 1, + triage_has_note: true, + }, +} as const; + +export const flatPassFindingWithoutPersistedTriage = { + type: "findings", + id: "finding-pass-1", + attributes: { + uid: "prowler-finding-pass-uid-1", + status: "PASS", + triage_id: null, + triage_status: null, + triage_notes_count: 0, + triage_has_note: false, + }, +} as const; + +export const flatFindingWithAcceptedRiskTriage = { + type: "findings", + id: "finding-accepted-risk-1", + attributes: { + uid: "prowler-finding-accepted-risk-uid-1", + status: "FAIL", + triage_id: "triage-accepted-risk-1", + triage_status: "risk_accepted", + triage_notes_count: 1, + triage_has_note: true, + triage_note: + "Accepted risk note body that must never appear in a table DTO.", + source: "manual", + updated_by: "user-1", + inserted_at: "2026-06-01T10:00:00Z", + updated_at: "2026-06-01T10:05:00Z", + }, +} as const; + +export const findingTriageDetailResponse = { + data: { + type: "finding-triages", + id: "triage-detail-1", + attributes: { + finding_id: "finding-1", + finding_uid: "prowler-finding-uid-1", + status: "risk_accepted", + triage_notes_count: 1, + has_note: true, + note_id: "note-detail-1", + current_note: "Current note visible only inside the modal.", + source: "manual", + updated_by: "user-1", + inserted_at: "2026-06-03T10:00:00Z", + updated_at: "2026-06-03T10:05:00Z", + }, + }, +} as const; diff --git a/ui/actions/findings/findings-triage.options.ts b/ui/actions/findings/findings-triage.options.ts new file mode 100644 index 0000000000..1d0ad6314a --- /dev/null +++ b/ui/actions/findings/findings-triage.options.ts @@ -0,0 +1,20 @@ +import { + FINDING_TRIAGE_DISABLED_REASON, + type FindingTriageDisabledReason, +} from "@/types/findings-triage"; + +interface FindingTriageAdapterOptions { + canEdit: boolean; + disabledReason?: FindingTriageDisabledReason; +} + +export function getFindingTriageAdapterOptions(): FindingTriageAdapterOptions { + const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + + return { + canEdit: isCloudEnvironment, + ...(isCloudEnvironment + ? {} + : { disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY }), + }; +} diff --git a/ui/actions/findings/findings-triage.test.ts b/ui/actions/findings/findings-triage.test.ts new file mode 100644 index 0000000000..d494b64be5 --- /dev/null +++ b/ui/actions/findings/findings-triage.test.ts @@ -0,0 +1,644 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage"; + +const { + createMuteRuleMock, + fetchMock, + getAuthHeadersMock, + handleApiResponseMock, +} = vi.hoisted(() => ({ + createMuteRuleMock: vi.fn(), + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiResponseMock: vi.fn(), +})); + +vi.mock("@/actions/mute-rules", () => ({ + createMuteRule: createMuteRuleMock, +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.test/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiResponse: handleApiResponseMock, +})); + +const importActions = async () => import("./findings-triage"); + +describe("findings triage actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + createMuteRuleMock.mockResolvedValue({ success: "muted" }); + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + }); + + it("should load notes through the persisted triage route when triageId exists", async () => { + // Given + const { loadLatestFindingTriageNote } = await importActions(); + handleApiResponseMock.mockResolvedValue({ + data: [ + { + id: "note-1", + type: "finding-triage-notes", + attributes: { body: "Existing note" }, + }, + ], + }); + + // When + const result = await loadLatestFindingTriageNote({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 1, + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + label: "Under Review", + hasVisibleNote: true, + isMuted: false, + canEdit: true, + billingHref: "https://prowler.com/pricing", + }); + + // Then + expect(result).toEqual({ noteId: "note-1", noteBody: "Existing note" }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/finding-triages/triage-1/notes", + expect.objectContaining({ + headers: { Authorization: "Bearer token" }, + }), + ); + }); + + it("should load notes through the finding UID route when triageId is virtual", async () => { + // Given + const { loadLatestFindingTriageNote } = await importActions(); + handleApiResponseMock.mockResolvedValue({ + data: [ + { + id: "note-1", + type: "finding-triage-notes", + attributes: { body: "Existing note" }, + }, + ], + }); + + // When + await loadLatestFindingTriageNote({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: null, + notesCount: 1, + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + label: "Under Review", + hasVisibleNote: true, + isMuted: false, + canEdit: true, + billingHref: "https://prowler.com/pricing", + }); + + // Then + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage/notes", + expect.any(Object), + ); + }); + + it("should resolve findingUid from findingId before loading virtual triage notes", async () => { + // Given + const { loadLatestFindingTriageNote } = await importActions(); + handleApiResponseMock + .mockResolvedValueOnce({ + data: { attributes: { uid: "finding/stable/uid" } }, + }) + .mockResolvedValueOnce({ + data: [ + { + id: "note-1", + type: "finding-triage-notes", + attributes: { body: "Existing note" }, + }, + ], + }); + + // When + await loadLatestFindingTriageNote({ + findingId: "finding-snapshot-id", + findingUid: "", + triageId: null, + notesCount: 1, + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + label: "Under Review", + hasVisibleNote: true, + isMuted: false, + canEdit: true, + billingHref: "https://prowler.com/pricing", + }); + + // Then + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.test/api/v1/findings/finding-snapshot-id", + expect.any(Object), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage/notes", + expect.any(Object), + ); + }); + + it("should send the first note with the status update through the triage route", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ data: { id: "note-1" } }); + + // When + await updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 0, + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + note: "First note", + }); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/finding-triages/triage-1", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ + data: { + type: "finding-triages", + attributes: { + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + note: "First note", + }, + }, + }), + }), + ); + }); + + it("should update an existing note through its note id", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ data: { id: "note-1" } }); + + // When + await updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 1, + noteId: "note-1", + note: "Updated note", + }); + + // Then + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/finding-triages/triage-1/notes/note-1", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ + data: { + type: "finding-triage-notes", + attributes: { + body: "Updated note", + }, + }, + }), + }), + ); + }); + + it("should delete an existing persisted note when it is cleared", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ data: { id: "note-1" } }); + + // When + await updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 1, + noteId: "note-1", + note: "", + }); + + // Then + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/finding-triages/triage-1/notes/note-1", + { + method: "DELETE", + headers: { Authorization: "Bearer token" }, + }, + ); + expect(fetchMock).not.toHaveBeenCalledWith( + "https://api.test/api/v1/finding-triages/triage-1/notes/note-1", + expect.objectContaining({ method: "PATCH" }), + ); + }); + + it("should update an existing note and send status-only triage patch", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } }); + + // When + await updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 1, + noteId: "note-1", + status: FINDING_TRIAGE_STATUS.REMEDIATING, + note: "Updated note", + }); + + // Then + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.test/api/v1/finding-triages/triage-1/notes/note-1", + expect.objectContaining({ method: "PATCH" }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://api.test/api/v1/finding-triages/triage-1", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ + data: { + type: "finding-triages", + attributes: { + status: FINDING_TRIAGE_STATUS.REMEDIATING, + }, + }, + }), + }), + ); + }); + + it("should update a virtual existing note through the finding UID note route", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } }); + + // When + await updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: null, + notesCount: 1, + noteId: "note-1", + status: FINDING_TRIAGE_STATUS.REMEDIATING, + previousStatus: FINDING_TRIAGE_STATUS.OPEN, + note: "Updated note", + }); + + // Then + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage/notes/note-1", + expect.objectContaining({ method: "PATCH" }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ + data: { + type: "finding-triages", + attributes: { + status: FINDING_TRIAGE_STATUS.REMEDIATING, + }, + }, + }), + }), + ); + }); + + it("should not patch virtual triage route for virtual existing-note-only updates", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ data: { id: "note-1" } }); + + // When + await updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: null, + notesCount: 1, + noteId: "note-1", + note: "Updated note", + }); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage/notes/note-1", + expect.objectContaining({ method: "PATCH" }), + ); + }); + + it("should delete a virtual existing note when it is cleared", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ data: { id: "note-1" } }); + + // When + await updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: null, + notesCount: 1, + noteId: "note-1", + note: "", + }); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage/notes/note-1", + expect.objectContaining({ + method: "DELETE", + headers: { Authorization: "Bearer token" }, + }), + ); + }); + + it("should create a mute rule when status is Risk Accepted", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } }); + + // When + await updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 0, + status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED, + previousStatus: FINDING_TRIAGE_STATUS.OPEN, + }); + + // Then + expect(createMuteRuleMock).toHaveBeenCalledOnce(); + const formData = createMuteRuleMock.mock.calls[0][1] as FormData; + expect(formData.get("finding_ids")).toBe( + JSON.stringify(["finding-snapshot-id"]), + ); + expect(formData.get("name")).toBe( + "Finding triage: Risk Accepted - finding-snapshot-id", + ); + expect(formData.get("reason")).toBe( + "Finding triage status changed to Risk Accepted.", + ); + }); + + it("should reject and skip muting when triage patch returns an action error", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ + error: "Triage failed", + status: 400, + }); + + // When / Then + await expect( + updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 0, + status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED, + previousStatus: FINDING_TRIAGE_STATUS.OPEN, + }), + ).rejects.toThrow("Triage failed"); + expect(createMuteRuleMock).not.toHaveBeenCalled(); + }); + + it("should rollback triage status when automatic muting fails", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } }); + createMuteRuleMock.mockResolvedValue({ + errors: { general: "Mute failed" }, + }); + + // When / Then + await expect( + updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 0, + status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED, + previousStatus: FINDING_TRIAGE_STATUS.OPEN, + }), + ).rejects.toThrow("Mute failed"); + + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://api.test/api/v1/finding-triages/triage-1", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ + data: { + type: "finding-triages", + attributes: { + status: FINDING_TRIAGE_STATUS.OPEN, + }, + }, + }), + }), + ); + }); + + it("should rollback virtual triage status through encoded finding UID when automatic muting fails", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } }); + createMuteRuleMock.mockResolvedValue({ + errors: { general: "Mute failed" }, + }); + + // When / Then + await expect( + updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: null, + notesCount: 0, + status: FINDING_TRIAGE_STATUS.FALSE_POSITIVE, + previousStatus: FINDING_TRIAGE_STATUS.OPEN, + }), + ).rejects.toThrow("Mute failed"); + + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ + data: { + type: "finding-triages", + attributes: { + status: FINDING_TRIAGE_STATUS.OPEN, + }, + }, + }), + }), + ); + }); + + it("should not create a mute rule when the finding is already muted", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } }); + + // When + await updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 0, + status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED, + previousStatus: FINDING_TRIAGE_STATUS.OPEN, + isMuted: true, + }); + + // Then + expect(createMuteRuleMock).not.toHaveBeenCalled(); + }); + + it("should not create a mute rule when moving between shortcut statuses", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } }); + + // When + await updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 0, + status: FINDING_TRIAGE_STATUS.FALSE_POSITIVE, + previousStatus: FINDING_TRIAGE_STATUS.RISK_ACCEPTED, + }); + + // Then + expect(createMuteRuleMock).not.toHaveBeenCalled(); + }); + + it("should not create a mute rule when shortcut status did not change", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } }); + + // When + await updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 0, + status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED, + previousStatus: FINDING_TRIAGE_STATUS.RISK_ACCEPTED, + note: "First note", + }); + + // Then + expect(createMuteRuleMock).not.toHaveBeenCalled(); + }); + + it("should not create a mute rule for regular triage statuses", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } }); + + // When + await updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 0, + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + }); + + // Then + expect(createMuteRuleMock).not.toHaveBeenCalled(); + }); + + it("should update virtual triage through the finding UID route, not the snapshot id", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } }); + + // When + await updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: null, + notesCount: 0, + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + }); + + // Then + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage", + expect.objectContaining({ method: "PATCH" }), + ); + }); + + it("should resolve findingUid from findingId before creating virtual triage", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock + .mockResolvedValueOnce({ + data: { attributes: { uid: "finding/stable/uid" } }, + }) + .mockResolvedValueOnce({ data: { id: "triage-1" } }); + + // When + await updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "", + triageId: null, + notesCount: 0, + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + note: "First note", + }); + + // Then + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.test/api/v1/findings/finding-snapshot-id", + expect.any(Object), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ + data: { + type: "finding-triages", + attributes: { + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + note: "First note", + }, + }, + }), + }), + ); + }); +}); diff --git a/ui/actions/findings/findings-triage.ts b/ui/actions/findings/findings-triage.ts new file mode 100644 index 0000000000..c08c7a1335 --- /dev/null +++ b/ui/actions/findings/findings-triage.ts @@ -0,0 +1,264 @@ +"use server"; + +import { adaptLatestFindingTriageNote } from "@/actions/findings/findings-triage.adapter"; +import { createMuteRule } from "@/actions/mute-rules"; +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiResponse } from "@/lib/server-actions-helper"; +import { + FINDING_TRIAGE_STATUS_LABELS, + type FindingTriageLoadedNote, + type FindingTriageSummary, + isMutelistShortcutStatus, + type UpdateFindingTriageInput, +} from "@/types/findings-triage"; + +const JSON_API_CONTENT_TYPE = "application/vnd.api+json"; + +const buildFindingTriageBody = ({ + status, + note, +}: { + status?: + | UpdateFindingTriageInput["status"] + | UpdateFindingTriageInput["previousStatus"]; + note?: UpdateFindingTriageInput["note"]; +}) => ({ + data: { + type: "finding-triages", + attributes: { + ...(status ? { status } : {}), + ...(note ? { note } : {}), + }, + }, +}); + +const buildFindingTriageNoteBody = (body: string) => ({ + data: { + type: "finding-triage-notes", + attributes: { + body, + }, + }, +}); + +const buildApiUrl = (path: `/${string}`) => { + if (!apiBaseUrl) { + throw new Error("API base URL is not configured."); + } + + const url = new URL(apiBaseUrl); + url.pathname = `${url.pathname.replace(/\/$/, "")}${path}`; + return url.toString(); +}; + +async function getJsonApi(path: `/${string}`) { + const headers = await getAuthHeaders({ contentType: false }); + const response = await fetch(buildApiUrl(path), { + headers, + }); + + return handleApiResponse(response); +} + +const throwIfApiError = (result: unknown) => { + if ( + result && + typeof result === "object" && + ("error" in result || + ("status" in result && + typeof result.status === "number" && + result.status >= 400)) + ) { + throw new Error( + "error" in result && typeof result.error === "string" + ? result.error + : "Finding triage request failed.", + ); + } +}; + +async function patchJsonApi(path: `/${string}`, body: unknown) { + const headers = await getAuthHeaders({ contentType: false }); + const response = await fetch(buildApiUrl(path), { + method: "PATCH", + headers: { + ...headers, + "Content-Type": JSON_API_CONTENT_TYPE, + }, + body: JSON.stringify(body), + }); + + const result = await handleApiResponse(response); + throwIfApiError(result); + return result; +} + +async function deleteJsonApi(path: `/${string}`) { + const headers = await getAuthHeaders({ contentType: false }); + const response = await fetch(buildApiUrl(path), { + method: "DELETE", + headers, + }); + + const result = await handleApiResponse(response); + throwIfApiError(result); + return result; +} + +const shouldCreateTriageMuteRule = ( + input: UpdateFindingTriageInput, +): input is UpdateFindingTriageInput & { + status: NonNullable; +} => + Boolean(input.status) && + input.previousStatus !== undefined && + input.status !== input.previousStatus && + input.isMuted !== true && + isMutelistShortcutStatus(input.status) && + !isMutelistShortcutStatus(input.previousStatus); + +async function createTriageMuteRule(input: UpdateFindingTriageInput) { + if (!shouldCreateTriageMuteRule(input)) { + return; + } + + const label = FINDING_TRIAGE_STATUS_LABELS[input.status]; + const formData = new FormData(); + formData.set("name", `Finding triage: ${label} - ${input.findingId}`); + formData.set("reason", `Finding triage status changed to ${label}.`); + formData.set("finding_ids", JSON.stringify([input.findingId])); + + const result = await createMuteRule(null, formData); + + if (result?.errors) { + throw new Error( + result.errors.general || + result.errors.finding_ids || + result.errors.name || + result.errors.reason || + "Could not mute finding after triage status change.", + ); + } +} + +const encodePathSegment = (value: string) => encodeURIComponent(value); + +async function rollbackTriageStatus( + input: UpdateFindingTriageInput, + findingUid?: string, +) { + if (!input.previousStatus || !shouldCreateTriageMuteRule(input)) { + return; + } + + const previousStatus = input.previousStatus; + + if (input.triageId) { + await patchJsonApi( + `/finding-triages/${input.triageId}`, + buildFindingTriageBody({ status: previousStatus }), + ); + return; + } + + if (findingUid) { + await patchJsonApi( + `/findings/${encodePathSegment(findingUid)}/triage`, + buildFindingTriageBody({ status: previousStatus }), + ); + } +} + +async function createMuteRuleOrRollback( + input: UpdateFindingTriageInput, + findingUid?: string, +) { + try { + await createTriageMuteRule(input); + } catch (error) { + try { + await rollbackTriageStatus(input, findingUid); + } catch (rollbackError) { + console.error("Could not rollback finding triage status.", rollbackError); + } + throw error; + } +} + +async function resolveFindingUid({ + findingId, + findingUid, +}: Pick) { + if (findingUid) { + return findingUid; + } + + const apiResponse = await getJsonApi( + `/findings/${encodePathSegment(findingId)}`, + ); + const resolvedFindingUid = apiResponse?.data?.attributes?.uid; + + if (typeof resolvedFindingUid !== "string" || !resolvedFindingUid) { + throw new Error("Cannot create finding triage without findingUid."); + } + + return resolvedFindingUid; +} + +export async function loadLatestFindingTriageNote( + triage: FindingTriageSummary, +): Promise { + const findingUid = triage.triageId + ? triage.findingUid + : await resolveFindingUid(triage); + const apiResponse = await getJsonApi( + triage.triageId + ? `/finding-triages/${triage.triageId}/notes` + : `/findings/${encodePathSegment(findingUid)}/triage/notes`, + ); + const latestNote = adaptLatestFindingTriageNote(apiResponse); + + if (!latestNote) { + throw new Error("Could not load the latest finding triage note."); + } + + return latestNote; +} + +export async function updateFindingTriage(input: UpdateFindingTriageInput) { + let findingUid: string | undefined; + let triagePath: `/${string}`; + + if (input.triageId) { + triagePath = `/finding-triages/${input.triageId}`; + } else { + findingUid = await resolveFindingUid(input); + triagePath = `/findings/${encodePathSegment(findingUid)}/triage`; + } + + if (input.note !== undefined && input.notesCount > 0 && input.noteId) { + const notePath: `/${string}` = `${triagePath}/notes/${input.noteId}`; + const noteResult = + input.note === "" + ? await deleteJsonApi(notePath) + : await patchJsonApi(notePath, buildFindingTriageNoteBody(input.note)); + + if (!input.status) { + return noteResult; + } + } + + if (!input.status && !(input.note && input.notesCount === 0)) { + return undefined; + } + + const result = await patchJsonApi( + triagePath, + buildFindingTriageBody({ + status: input.status, + note: input.notesCount === 0 && input.note ? input.note : undefined, + }), + ); + await createMuteRuleOrRollback(input, findingUid); + return result; +} diff --git a/ui/actions/findings/findings.test.ts b/ui/actions/findings/findings.test.ts new file mode 100644 index 0000000000..efe04f119c --- /dev/null +++ b/ui/actions/findings/findings.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + fetchMock, + getAuthHeadersMock, + handleApiResponseMock, + appendSanitizedProviderTypeFiltersMock, + redirectMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiResponseMock: vi.fn(), + appendSanitizedProviderTypeFiltersMock: vi.fn(), + redirectMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: redirectMock, +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/provider-filters", () => ({ + appendSanitizedProviderTypeFilters: appendSanitizedProviderTypeFiltersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiResponse: handleApiResponseMock, +})); + +import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage"; + +import { getFindings, getLatestFindings } from "./findings"; + +const findingsResponse = { + data: [ + { + type: "findings", + id: "finding-1", + attributes: { + uid: "prowler-finding-uid-1", + status: "FAIL", + triage_status: "under_review", + triage_has_note: true, + }, + }, + ], + meta: { pagination: { page: 1 } }, +}; + +describe("findings actions triage projection", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + handleApiResponseMock.mockResolvedValue(findingsResponse); + }); + + it("should attach domain triage DTOs to historical findings responses", async () => { + // When + const result = await getFindings({ page: 1, pageSize: 10 }); + + // Then + expect(result?.data[0].triage).toEqual( + expect.objectContaining({ + findingId: "finding-1", + findingUid: "prowler-finding-uid-1", + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + label: "Under Review", + hasVisibleNote: true, + canEdit: false, + disabledReason: "cloud_only", + }), + ); + expect(result?.data[0].triage).not.toHaveProperty("triage_status"); + expect(result?.data[0].triage).not.toHaveProperty("attributes"); + }); + + it("should attach domain triage DTOs to latest findings responses", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + + // When + const result = await getLatestFindings({ page: 1, pageSize: 10 }); + + // Then + expect(result?.data[0].triage).toEqual( + expect.objectContaining({ + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + canEdit: true, + }), + ); + expect(result?.data[0].triage).not.toHaveProperty("disabledReason"); + }); +}); diff --git a/ui/actions/findings/findings.ts b/ui/actions/findings/findings.ts index 242bd007a8..0635d273ce 100644 --- a/ui/actions/findings/findings.ts +++ b/ui/actions/findings/findings.ts @@ -2,9 +2,21 @@ import { redirect } from "next/navigation"; +import { attachFindingTriageSummariesToResponse } from "@/actions/findings/findings-triage.adapter"; +import { getFindingTriageAdapterOptions } from "@/actions/findings/findings-triage.options"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; + +const withFindingTriageSummaries = ( + response: T | undefined, +): T | undefined => { + return attachFindingTriageSummariesToResponse( + response, + getFindingTriageAdapterOptions(), + ); +}; + export const getFindings = async ({ page = 1, pageSize = 10, @@ -31,8 +43,9 @@ export const getFindings = async ({ const findings = await fetch(url.toString(), { headers, }); + const response = await handleApiResponse(findings); - return handleApiResponse(findings); + return withFindingTriageSummaries(response); } catch (error) { console.error("Error fetching findings:", error); return undefined; @@ -67,8 +80,9 @@ export const getLatestFindings = async ({ const findings = await fetch(url.toString(), { headers, }); + const response = await handleApiResponse(findings); - return handleApiResponse(findings); + return withFindingTriageSummaries(response); } catch (error) { console.error("Error fetching findings:", error); return undefined; diff --git a/ui/actions/findings/index.ts b/ui/actions/findings/index.ts index d9fd5ae3b5..c4de6f1945 100644 --- a/ui/actions/findings/index.ts +++ b/ui/actions/findings/index.ts @@ -1,3 +1,4 @@ export * from "./findings"; export * from "./findings-by-resource"; export * from "./findings-by-resource.adapter"; +export * from "./findings-triage"; diff --git a/ui/actions/manage-groups/manage-groups.test.ts b/ui/actions/manage-groups/manage-groups.test.ts new file mode 100644 index 0000000000..c016832a1f --- /dev/null +++ b/ui/actions/manage-groups/manage-groups.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + fetchMock, + getAuthHeadersMock, + handleApiErrorMock, + handleApiResponseMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiErrorMock: vi.fn(), + handleApiResponseMock: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, + getErrorMessage: vi.fn(), +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: handleApiErrorMock, + handleApiResponse: handleApiResponseMock, +})); + +import { getAllProviderGroups } from "./manage-groups"; + +const makeGroup = (id: string, name: string) => ({ + type: "provider-groups" as const, + id, + attributes: { name, inserted_at: "", updated_at: "" }, + relationships: { + providers: { meta: { count: 0 }, data: [] }, + roles: { meta: { count: 0 }, data: [] }, + }, + links: { self: "" }, +}); + +const makePage = ( + data: ReturnType[], + page: number, + pages: number, +) => ({ + links: { first: "", last: "", next: null, prev: null }, + data, + meta: { pagination: { page, pages, count: data.length } }, +}); + +describe("getAllProviderGroups", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + }); + + it("merges every page into a single response with collapsed pagination", async () => { + handleApiResponseMock + .mockResolvedValueOnce( + makePage( + [makeGroup("g1", "Group 1"), makeGroup("g2", "Group 2")], + 1, + 2, + ), + ) + .mockResolvedValueOnce(makePage([makeGroup("g3", "Group 3")], 2, 2)); + + const result = await getAllProviderGroups(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result?.data.map((group) => group.id)).toEqual(["g1", "g2", "g3"]); + expect(result?.meta.pagination).toMatchObject({ + page: 1, + pages: 1, + count: 3, + }); + }); + + it("stops after the first page when there is only one page", async () => { + handleApiResponseMock.mockResolvedValueOnce( + makePage([makeGroup("g1", "Group 1")], 1, 1), + ); + + const result = await getAllProviderGroups(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result?.data).toHaveLength(1); + }); + + it("returns undefined when the first page has no data", async () => { + handleApiResponseMock.mockResolvedValueOnce(makePage([], 1, 1)); + + const result = await getAllProviderGroups(); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when the request throws", async () => { + fetchMock.mockRejectedValueOnce(new Error("network down")); + + const result = await getAllProviderGroups(); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when a later page resolves to an error payload", async () => { + handleApiResponseMock + .mockResolvedValueOnce(makePage([makeGroup("g1", "Group 1")], 1, 2)) + .mockResolvedValueOnce({ error: "Forbidden", status: 403 }); + + const result = await getAllProviderGroups(); + + expect(result).toBeUndefined(); + }); + + it("returns undefined instead of a truncated list when the max-page cap is hit", async () => { + // Given an API that always reports more pages than the 50-page safety cap + handleApiResponseMock.mockImplementation((response: Response) => { + void response; + return Promise.resolve(makePage([makeGroup("g", "Group")], 1, 9999)); + }); + + // When fetching every page + const result = await getAllProviderGroups(); + + // Then it must not return a partial/truncated list; bail out instead + expect(result).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(50); + }); +}); diff --git a/ui/actions/manage-groups/manage-groups.ts b/ui/actions/manage-groups/manage-groups.ts index 933dabbdbc..c916a89d39 100644 --- a/ui/actions/manage-groups/manage-groups.ts +++ b/ui/actions/manage-groups/manage-groups.ts @@ -51,6 +51,87 @@ export const getProviderGroups = async ({ } }; +/** + * Fetches all provider groups by iterating through every page. + * Used to populate filter dropdowns (e.g. the Provider Group selector) without + * the pagination cap that `getProviderGroups` applies for the management table. + */ +export const getAllProviderGroups = async (): Promise< + ProviderGroupsResponse | undefined +> => { + const pageSize = 100; // Larger page size to minimize API calls + const maxPages = 50; // Safety limit: 50 pages × 100 = 5000 groups max + let currentPage = 1; + const allGroups: ProviderGroupsResponse["data"] = []; + let lastResponse: ProviderGroupsResponse | undefined; + let hasMorePages = true; + + try { + const headers = await getAuthHeaders({ contentType: false }); + while (hasMorePages && currentPage <= maxPages) { + const url = new URL(`${apiBaseUrl}/provider-groups`); + url.searchParams.append("page[number]", currentPage.toString()); + url.searchParams.append("page[size]", pageSize.toString()); + + const response = await fetch(url.toString(), { headers }); + const data = (await handleApiResponse(response)) as + | ProviderGroupsResponse + | { error: string; status?: number } + | undefined; + + // A later page resolving to an API error payload must abort rather than + // be treated as "no more pages", which would silently truncate groups. + if (data && "error" in data) { + console.error("Error fetching all provider groups:", data.error); + return undefined; + } + + if (!data?.data || data.data.length === 0) { + hasMorePages = false; + continue; + } + + allGroups.push(...data.data); + lastResponse = data; + + const totalPages = data.meta?.pagination?.pages || 1; + if (currentPage >= totalPages) { + hasMorePages = false; + } else { + currentPage++; + } + } + + if (hasMorePages && currentPage > maxPages) { + console.error( + `Error fetching all provider groups: exceeded max page limit (${maxPages})`, + ); + return undefined; + } + + if (lastResponse) { + return { + ...lastResponse, + data: allGroups, + meta: { + ...lastResponse.meta, + pagination: { + ...lastResponse.meta?.pagination, + page: 1, + pages: 1, + count: allGroups.length, + }, + }, + }; + } + + return undefined; + } catch (error) { + console.error("Error fetching all provider groups:", error); + return undefined; + } +}; + export const getProviderGroupInfoById = async (providerGroupId: string) => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/provider-groups/${providerGroupId}`); diff --git a/ui/actions/mute-rules/mute-rules.ts b/ui/actions/mute-rules/mute-rules.ts index 5b571e2bba..c74c4a7e2a 100644 --- a/ui/actions/mute-rules/mute-rules.ts +++ b/ui/actions/mute-rules/mute-rules.ts @@ -158,17 +158,24 @@ export const createMuteRule = async ( try { if (responseContentType?.includes("application/json")) { const errorData = await response.json(); + const jsonApiError = ( + errorData as { + errors?: Array<{ + detail?: string; + title?: string; + source?: { pointer?: string }; + }>; + message?: string; + } + )?.errors?.[0]; errorMessage = - ( - errorData as { - errors?: Array<{ detail?: string }>; - message?: string; - } - )?.errors?.[0]?.detail || + jsonApiError?.detail || + jsonApiError?.title || (errorData as { message?: string })?.message || errorMessage; } else { - await response.text(); + const responseText = await response.text(); + errorMessage = responseText || errorMessage; } } catch { // JSON parsing failed, use default error message diff --git a/ui/actions/overview/overview-filters.ts b/ui/actions/overview/overview-filters.ts new file mode 100644 index 0000000000..186977f9ce --- /dev/null +++ b/ui/actions/overview/overview-filters.ts @@ -0,0 +1,16 @@ +import { FILTER_FIELD, FilterParam } from "@/types/filters"; + +/** + * URL filter param keys the overview dashboard scopes its widgets by. Overview has + * no single action; its widgets read these keys from the URL filters. + */ +export type OverviewFilterParam = FilterParam< + (typeof FILTER_FIELD)["PROVIDER_TYPE" | "PROVIDER_ID" | "PROVIDER_GROUPS"] +>; + +/** The `filter[...]` keys overview widgets read from the URL. */ +export const OVERVIEW_FILTER_PARAM = { + PROVIDER_TYPE: `filter[${FILTER_FIELD.PROVIDER_TYPE}]`, + PROVIDER_ID: `filter[${FILTER_FIELD.PROVIDER_ID}]`, + PROVIDER_GROUPS: `filter[${FILTER_FIELD.PROVIDER_GROUPS}]`, +} as const satisfies Record; diff --git a/ui/actions/providers/providers-filters.ts b/ui/actions/providers/providers-filters.ts new file mode 100644 index 0000000000..71184c0b81 --- /dev/null +++ b/ui/actions/providers/providers-filters.ts @@ -0,0 +1,18 @@ +import { FILTER_FIELD, FilterParam } from "@/types/filters"; +import { PROVIDERS_PAGE_FILTER } from "@/types/providers-table"; + +/** + * URL filter param keys the providers list supports, e.g. `filter[provider__in]`. + * Provider scope plus its providers-only extras (`provider__in` API param, + * `connected` status). + */ +export type ProvidersFilterParam = FilterParam< + | (typeof FILTER_FIELD)["PROVIDER_TYPE" | "PROVIDER_GROUPS" | "PROVIDER_UID"] + | (typeof PROVIDERS_PAGE_FILTER)["PROVIDER" | "STATUS"] +>; + +/** `filter[...]` keys used when mapping the provider-type filter to the API param. */ +export const PROVIDERS_FILTER_PARAM = { + PROVIDER: `filter[${PROVIDERS_PAGE_FILTER.PROVIDER}]`, + PROVIDER_TYPE: `filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`, +} as const satisfies Record; diff --git a/ui/actions/resources/resources-filters.ts b/ui/actions/resources/resources-filters.ts new file mode 100644 index 0000000000..01132943a6 --- /dev/null +++ b/ui/actions/resources/resources-filters.ts @@ -0,0 +1,25 @@ +import { FILTER_FIELD, FilterParam } from "@/types/filters"; + +/** Resources-only filter fields not shared with other views. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const RESOURCES_EXTRA_FIELD = { + TYPE: "type__in", + GROUPS: "groups__in", +} as const; + +type ResourcesExtraField = + (typeof RESOURCES_EXTRA_FIELD)[keyof typeof RESOURCES_EXTRA_FIELD]; + +/** + * URL filter param keys the resources view supports, e.g. `filter[type__in]`. + * The shared core plus its resources-only dimensions (`type__in`, `groups__in`). + */ +export type ResourcesFilterParam = FilterParam< + | (typeof FILTER_FIELD)[ + | "PROVIDER_TYPE" + | "PROVIDER_ID" + | "PROVIDER_GROUPS" + | "REGION" + | "SERVICE"] + | ResourcesExtraField +>; diff --git a/ui/actions/resources/resources.test.ts b/ui/actions/resources/resources.test.ts index d8c5d75456..5f45b7815a 100644 --- a/ui/actions/resources/resources.test.ts +++ b/ui/actions/resources/resources.test.ts @@ -47,6 +47,14 @@ vi.mock("@/lib/server-actions-helper", () => ({ handleApiResponse: handleApiResponseMock, })); +vi.mock("@/actions/findings", () => ({ + getLatestFindings: vi.fn(), +})); + +vi.mock("@/actions/organizations/organizations", () => ({ + listOrganizationsSafe: vi.fn(), +})); + vi.mock("@/lib/provider-filters", () => ({ appendSanitizedProviderTypeFilters: vi.fn(), })); @@ -102,29 +110,6 @@ describe("getResourceEvents", () => { expect(calledUrl.searchParams.get("page[size]")).toBe("25"); }); - it("returns parsed response on success", async () => { - // Given - const mockData = { - data: [ - { - type: "resource-events", - id: "event-1", - attributes: { event_name: "CreateStack" }, - }, - ], - }; - const mockResponse = new Response("", { status: 200 }); - fetchMock.mockResolvedValue(mockResponse); - handleApiResponseMock.mockResolvedValue(mockData); - - // When - const result = await getResourceEvents("resource-123"); - - // Then - expect(result).toEqual(mockData); - expect(handleApiResponseMock).toHaveBeenCalledWith(mockResponse); - }); - it("returns error object for non-ok responses without calling handleApiResponse", async () => { // Given const errorBody = JSON.stringify({ diff --git a/ui/actions/scan-configurations/index.ts b/ui/actions/scan-configurations/index.ts new file mode 100644 index 0000000000..d9631969df --- /dev/null +++ b/ui/actions/scan-configurations/index.ts @@ -0,0 +1 @@ +export * from "./scan-configurations"; diff --git a/ui/actions/scan-configurations/scan-configurations.ts b/ui/actions/scan-configurations/scan-configurations.ts new file mode 100644 index 0000000000..946fdeca82 --- /dev/null +++ b/ui/actions/scan-configurations/scan-configurations.ts @@ -0,0 +1,421 @@ +"use server"; + +import yaml from "js-yaml"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib/helper"; +import { scanConfigurationFormSchema } from "@/types/formSchemas"; +import { + DeleteScanConfigurationActionState, + ScanConfigurationActionState, + ScanConfigurationData, + ScanConfigurationErrors, + ScanConfigurationRequestBody, +} from "@/types/scan-configurations"; + +const SCAN_CONFIGURATION_PATH = "/scans/config"; + +// Scan Configuration IDs are UUIDs. Validate before interpolating into request +// URLs so a malformed/crafted value can't inject path segments (SSRF / path +// injection). +const scanConfigurationIdSchema = z.uuid(); + +// Provider IDs are UUIDs too. Validate the whole array at the action boundary so +// a malformed/crafted id fails here instead of relying on API-side validation. +const providerIdsSchema = z.array(z.uuid()); + +const parseConfiguration = (value: string): Record => { + // Backend (YamlOrJsonField) accepts either a YAML string or a JSON object. + // We parse client-side so failures surface as form errors, not 500s. + const parsed = yaml.load(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Configuration must be a mapping with provider sections."); + } + return parsed as Record; +}; + +const collectProviderIds = (formData: FormData): string[] => { + return formData + .getAll("provider_ids") + .map((v) => String(v)) + .filter(Boolean); +}; + +interface ApiErrorSource { + pointer?: string; +} + +interface ApiError { + detail?: string; + title?: string; + source?: ApiErrorSource; +} + +// Route each JSON:API error to the matching form field via its `source.pointer` +// so it renders inline next to the offending input. Only errors we can't anchor +// to a field fall back to `general` (surfaced as a toast). Shared by create and +// update so both flows present validation errors identically — otherwise a +// config error shows inline on create but as a toast on update. +const mapApiErrorsToFields = ( + errorData: { errors?: ApiError[]; message?: string } | null | undefined, + fallbackMessage: string, +): ScanConfigurationErrors => { + const apiErrors = Array.isArray(errorData?.errors) ? errorData!.errors! : []; + + if (apiErrors.length === 0) { + return { general: errorData?.message || fallbackMessage }; + } + + const errors: ScanConfigurationErrors = {}; + const append = (key: keyof ScanConfigurationErrors, detail: string) => { + errors[key] = errors[key] ? `${errors[key]}\n${detail}` : detail; + }; + + for (const err of apiErrors) { + const detail = err?.detail || err?.title || fallbackMessage; + const pointer = err?.source?.pointer; + if (pointer?.includes("name")) append("name", detail); + else if (pointer?.includes("configuration")) + append("configuration", detail); + else if (pointer?.includes("provider_ids")) append("provider_ids", detail); + else append("general", detail); + } + return errors; +}; + +export const createScanConfiguration = async ( + _prevState: ScanConfigurationActionState, + formData: FormData, +): Promise => { + const headers = await getAuthHeaders({ contentType: true }); + const formDataObject = { + name: formData.get("name"), + configuration: formData.get("configuration"), + provider_ids: collectProviderIds(formData), + }; + + const validated = scanConfigurationFormSchema.safeParse(formDataObject); + if (!validated.success) { + const fieldErrors = validated.error.flatten().fieldErrors; + return { + errors: { + name: fieldErrors?.name?.[0], + configuration: fieldErrors?.configuration?.[0], + provider_ids: fieldErrors?.provider_ids?.[0], + }, + }; + } + + const { name, configuration, provider_ids } = validated.data; + + let parsedConfig: Record; + try { + parsedConfig = parseConfiguration(configuration); + } catch (e) { + return { + errors: { + configuration: + e instanceof Error ? e.message : "Failed to parse configuration", + }, + }; + } + + try { + const url = new URL(`${apiBaseUrl}/scan-configurations`); + const bodyData: ScanConfigurationRequestBody = { + data: { + type: "scan-configurations", + attributes: { + name, + configuration: parsedConfig, + provider_ids, + }, + }, + }; + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(bodyData), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { + errors: mapApiErrorsToFields( + errorData, + `Failed to create Scan Configuration: ${response.statusText}`, + ), + }; + } + + const data = await response.json(); + revalidatePath(SCAN_CONFIGURATION_PATH); + return { + success: "Scan Configuration created successfully!", + data: data.data as ScanConfigurationData, + }; + } catch (error) { + console.error("Error creating Scan Configuration:", error); + return { + errors: { + general: + error instanceof Error + ? error.message + : "Error creating Scan Configuration. Please try again.", + }, + }; + } +}; + +export const updateScanConfiguration = async ( + _prevState: ScanConfigurationActionState, + formData: FormData, +): Promise => { + const id = formData.get("id"); + if (!id) { + return { + errors: { general: "Scan Configuration ID is required for update" }, + }; + } + const idResult = scanConfigurationIdSchema.safeParse(String(id)); + if (!idResult.success) { + return { errors: { general: "Invalid Scan Configuration ID" } }; + } + const validId = idResult.data; + const headers = await getAuthHeaders({ contentType: true }); + const formDataObject = { + name: formData.get("name"), + configuration: formData.get("configuration"), + provider_ids: collectProviderIds(formData), + }; + + const validated = scanConfigurationFormSchema.safeParse(formDataObject); + if (!validated.success) { + const fieldErrors = validated.error.flatten().fieldErrors; + return { + errors: { + name: fieldErrors?.name?.[0], + configuration: fieldErrors?.configuration?.[0], + provider_ids: fieldErrors?.provider_ids?.[0], + }, + }; + } + + const { name, configuration, provider_ids } = validated.data; + + let parsedConfig: Record; + try { + parsedConfig = parseConfiguration(configuration); + } catch (e) { + return { + errors: { + configuration: + e instanceof Error ? e.message : "Failed to parse configuration", + }, + }; + } + + try { + const url = new URL(`${apiBaseUrl}/scan-configurations/${validId}`); + const bodyData: ScanConfigurationRequestBody = { + data: { + type: "scan-configurations", + id: validId, + attributes: { + name, + configuration: parsedConfig, + provider_ids, + }, + }, + }; + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(bodyData), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { + errors: mapApiErrorsToFields( + errorData, + `Failed to update Scan Configuration: ${response.statusText}`, + ), + }; + } + + const data = await response.json(); + revalidatePath(SCAN_CONFIGURATION_PATH); + return { + success: "Scan Configuration updated successfully!", + data: data.data as ScanConfigurationData, + }; + } catch (error) { + console.error("Error updating Scan Configuration:", error); + return { + errors: { + general: + error instanceof Error + ? error.message + : "Error updating Scan Configuration. Please try again.", + }, + }; + } +}; + +// Attach/detach providers on a scan configuration without touching its name or +// YAML — a partial PATCH of `provider_ids` only. Used by the provider row to +// associate/disassociate a config (editing the config itself lives in the Scan +// Config view). The backend's `(tenant, provider)` uniqueness means attaching a +// provider here moves it off any other config automatically. +export const setScanConfigurationProviders = async ( + configId: string, + providerIds: string[], +): Promise => { + const idResult = scanConfigurationIdSchema.safeParse(configId); + if (!idResult.success) { + return { errors: { general: "Invalid Scan Configuration ID" } }; + } + const validId = idResult.data; + const providerIdsResult = providerIdsSchema.safeParse(providerIds); + if (!providerIdsResult.success) { + return { errors: { provider_ids: "Invalid provider ID" } }; + } + const validProviderIds = providerIdsResult.data; + const headers = await getAuthHeaders({ contentType: true }); + + try { + const url = new URL(`${apiBaseUrl}/scan-configurations/${validId}`); + // Partial update: only provider_ids (name/configuration are optional on the + // backend update serializer), so we don't type this as the full request body. + const bodyData = { + data: { + type: "scan-configurations" as const, + id: validId, + attributes: { provider_ids: validProviderIds }, + }, + }; + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(bodyData), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { + errors: mapApiErrorsToFields( + errorData, + `Failed to update Scan Configuration: ${response.statusText}`, + ), + }; + } + + revalidatePath(SCAN_CONFIGURATION_PATH); + revalidatePath("/providers"); + return { success: "Scan Configuration updated successfully!" }; + } catch (error) { + console.error("Error updating Scan Configuration providers:", error); + return { + errors: { + general: + error instanceof Error + ? error.message + : "Error updating Scan Configuration. Please try again.", + }, + }; + } +}; + +export const listScanConfigurations = async (): Promise< + ScanConfigurationData[] +> => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/scan-configurations`); + + try { + const response = await fetch(url.toString(), { + method: "GET", + headers, + }); + if (!response.ok) { + throw new Error( + `Failed to list Scan Configurations: ${response.statusText}`, + ); + } + const json = await response.json(); + return (json.data || []) as ScanConfigurationData[]; + } catch (error) { + // Re-throw so callers can distinguish a fetch/auth failure from an empty + // result. Collapsing errors into `[]` would render a false "no scan + // configurations" state and overwrite the table on a failed refresh. + console.error("Error listing Scan Configurations:", error); + throw error; + } +}; + +export const getScanConfiguration = async ( + id: string, +): Promise => { + const idResult = scanConfigurationIdSchema.safeParse(id); + if (!idResult.success) return undefined; + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/scan-configurations/${idResult.data}`); + + try { + const response = await fetch(url.toString(), { + method: "GET", + headers, + }); + if (!response.ok) return undefined; + const json = await response.json(); + return json.data as ScanConfigurationData; + } catch (error) { + console.error("Error fetching Scan Configuration:", error); + return undefined; + } +}; + +export const deleteScanConfiguration = async ( + _prevState: DeleteScanConfigurationActionState, + formData: FormData, +): Promise => { + const headers = await getAuthHeaders({ contentType: true }); + const id = formData.get("id"); + if (!id) { + return { + errors: { general: "Scan Configuration ID is required for deletion" }, + }; + } + const idResult = scanConfigurationIdSchema.safeParse(String(id)); + if (!idResult.success) { + return { errors: { general: "Invalid Scan Configuration ID" } }; + } + try { + const url = new URL(`${apiBaseUrl}/scan-configurations/${idResult.data}`); + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.errors?.[0]?.detail || + `Failed to delete Scan Configuration: ${response.statusText}`, + ); + } + revalidatePath(SCAN_CONFIGURATION_PATH); + return { success: "Scan Configuration deleted successfully!" }; + } catch (error) { + console.error("Error deleting Scan Configuration:", error); + return { + errors: { + general: + error instanceof Error + ? error.message + : "Error deleting Scan Configuration. Please try again.", + }, + }; + } +}; diff --git a/ui/actions/scans/scans-filters.ts b/ui/actions/scans/scans-filters.ts new file mode 100644 index 0000000000..19958e9fa5 --- /dev/null +++ b/ui/actions/scans/scans-filters.ts @@ -0,0 +1,34 @@ +import { FILTER_FIELD, FilterParam } from "@/types/filters"; + +/** + * Provider filter fields used to match/clear synthetic pending scan rows — the + * `__in` forms (shared with real scan rows) plus the exact forms, and the + * provider-group `__in` form so pending rows honor the group filter too. + */ +export const SCANS_PROVIDER_FILTER_FIELD = { + PROVIDER_IN: FILTER_FIELD.PROVIDER, + PROVIDER: "provider", + PROVIDER_TYPE_IN: FILTER_FIELD.PROVIDER_TYPE, + PROVIDER_TYPE: "provider_type", + PROVIDER_GROUPS_IN: FILTER_FIELD.PROVIDER_GROUPS, +} as const; + +/** Scans-only filter fields not shared with other views. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const SCANS_EXTRA_FIELD = { + STATE: "state__in", + TRIGGER: "trigger", +} as const; + +type ScansExtraField = + (typeof SCANS_EXTRA_FIELD)[keyof typeof SCANS_EXTRA_FIELD]; + +/** + * URL filter param keys the scans view supports, e.g. `filter[state__in]`. + * Provider scope (scans filters accounts by provider id) including provider + * groups and the exact pending-row provider forms, plus the scans-only dimensions. + */ +export type ScansFilterParam = FilterParam< + | (typeof SCANS_PROVIDER_FILTER_FIELD)[keyof typeof SCANS_PROVIDER_FILTER_FIELD] + | ScansExtraField +>; diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx index 13f37fad4f..e69d0427ee 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx @@ -2,7 +2,7 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import { FilterType } from "@/types/filters"; +import { FILTER_FIELD } from "@/types/filters"; import { AccountsSelector } from "./accounts-selector"; @@ -189,7 +189,7 @@ describe("AccountsSelector", () => { render( , ); diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index 8a7c08b11c..ab5c27fd6c 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -17,7 +17,7 @@ import { MultiSelectValue, } from "@/components/shadcn/select/multiselect"; import { useUrlFilters } from "@/hooks/use-url-filters"; -import { type AccountFilterKey, FilterType } from "@/types/filters"; +import { type AccountFilterKey, FILTER_FIELD } from "@/types/filters"; import { getProviderDisplayName, type ProviderProps, @@ -68,7 +68,7 @@ export function AccountsSelector({ providers, onBatchChange, selectedValues, - filterKey = FilterType.PROVIDER_ID, + filterKey = FILTER_FIELD.PROVIDER_ID, id = "accounts-selector", disabledValues = [], search = { @@ -91,7 +91,7 @@ export function AccountsSelector({ const visibleProviders = providers; const getProviderValue = (provider: ProviderProps) => - filterKey === FilterType.PROVIDER_UID + filterKey === FILTER_FIELD.PROVIDER_UID ? provider.attributes.uid : provider.id; const disabledValuesSet = new Set(disabledValues); diff --git a/ui/app/(prowler)/_overview/_lib/provider-scope.test.ts b/ui/app/(prowler)/_overview/_lib/provider-scope.test.ts new file mode 100644 index 0000000000..3071f84837 --- /dev/null +++ b/ui/app/(prowler)/_overview/_lib/provider-scope.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; + +import { ProviderProps } from "@/types/providers"; + +import { + filterProvidersByScope, + parseFilterIds, + scopeProvidersByGroup, +} from "./provider-scope"; + +const makeProvider = ( + id: string, + provider: string, + groupIds: string[] = [], +): ProviderProps => + ({ + id, + attributes: { provider }, + relationships: { + provider_groups: { + data: groupIds.map((gid) => ({ type: "provider-groups", id: gid })), + }, + }, + }) as unknown as ProviderProps; + +describe("parseFilterIds", () => { + it("returns an empty array for undefined", () => { + // Given / When / Then + expect(parseFilterIds(undefined)).toEqual([]); + }); + + it("returns an empty array for an empty string", () => { + // Given an empty param value (e.g. "filter[provider_groups__in]=") + // When / Then it must not produce a [""] match + expect(parseFilterIds("")).toEqual([]); + }); + + it("drops whitespace-only and empty segments", () => { + // Given a blank/whitespace value + // When / Then + expect(parseFilterIds(" ")).toEqual([]); + expect(parseFilterIds(",")).toEqual([]); + expect(parseFilterIds("a,,b")).toEqual(["a", "b"]); + }); + + it("splits and trims comma-separated ids", () => { + expect(parseFilterIds(" a , b ")).toEqual(["a", "b"]); + }); + + it("normalizes array param values", () => { + expect(parseFilterIds(["a", "", "b"])).toEqual(["a", "b"]); + }); +}); + +describe("scopeProvidersByGroup", () => { + const providers = [ + makeProvider("p1", "aws", ["g1"]), + makeProvider("p2", "gcp", ["g2"]), + makeProvider("p3", "azure", []), + ]; + + it("returns every provider when no group is selected", () => { + expect(scopeProvidersByGroup(providers, [])).toEqual(providers); + }); + + it("keeps only providers that belong to a selected group", () => { + // When scoping to g1 + const result = scopeProvidersByGroup(providers, ["g1"]); + + // Then only the g1 member remains + expect(result.map((p) => p.id)).toEqual(["p1"]); + }); + + it("excludes providers with no group memberships", () => { + expect(scopeProvidersByGroup(providers, ["g2"]).map((p) => p.id)).toEqual([ + "p2", + ]); + }); +}); + +describe("filterProvidersByScope", () => { + const providers = [ + makeProvider("p1", "aws", ["g1"]), + makeProvider("p2", "gcp", ["g1"]), + makeProvider("p3", "aws", ["g2"]), + makeProvider("p4", "azure", []), + ]; + + it("returns every provider when no dimension is set", () => { + const result = filterProvidersByScope(providers, { + providerIds: [], + providerTypes: [], + providerGroupIds: [], + }); + + expect(result).toEqual(providers); + }); + + it("filters by provider id", () => { + const result = filterProvidersByScope(providers, { + providerIds: ["p2"], + providerTypes: [], + providerGroupIds: [], + }); + + expect(result.map((p) => p.id)).toEqual(["p2"]); + }); + + it("filters by provider type case-insensitively", () => { + const result = filterProvidersByScope(providers, { + providerIds: [], + providerTypes: ["AWS"], + providerGroupIds: [], + }); + + expect(result.map((p) => p.id)).toEqual(["p1", "p3"]); + }); + + it("filters by provider group", () => { + const result = filterProvidersByScope(providers, { + providerIds: [], + providerTypes: [], + providerGroupIds: ["g1"], + }); + + expect(result.map((p) => p.id)).toEqual(["p1", "p2"]); + }); + + it("composes group AND type (the risk-plot regression)", () => { + // Given both a group and a type filter are active + // When combining group g1 with type aws + const result = filterProvidersByScope(providers, { + providerIds: [], + providerTypes: ["aws"], + providerGroupIds: ["g1"], + }); + + // Then only providers matching BOTH survive (p1), not all aws or all g1 + expect(result.map((p) => p.id)).toEqual(["p1"]); + }); + + it("composes id AND group", () => { + // p3 is aws/g2; selecting it together with group g1 yields nothing + const result = filterProvidersByScope(providers, { + providerIds: ["p3"], + providerTypes: [], + providerGroupIds: ["g1"], + }); + + expect(result).toEqual([]); + }); + + it("composes all three dimensions", () => { + const result = filterProvidersByScope(providers, { + providerIds: ["p1", "p2"], + providerTypes: ["aws"], + providerGroupIds: ["g1"], + }); + + expect(result.map((p) => p.id)).toEqual(["p1"]); + }); +}); diff --git a/ui/app/(prowler)/_overview/_lib/provider-scope.ts b/ui/app/(prowler)/_overview/_lib/provider-scope.ts new file mode 100644 index 0000000000..49973b201b --- /dev/null +++ b/ui/app/(prowler)/_overview/_lib/provider-scope.ts @@ -0,0 +1,71 @@ +import { ProviderProps } from "@/types/providers"; + +export interface ProviderScopeFilters { + providerIds: string[]; + providerTypes: string[]; + providerGroupIds: string[]; +} + +/** + * Normalize a comma-separated filter param into trimmed, non-empty ids. + * Guards against blank values (e.g. an empty "filter[...]=" param) so they are + * treated as "no filter" instead of matching against an empty-string id. + */ +export const parseFilterIds = ( + value: string | string[] | undefined, +): string[] => { + if (value === undefined) return []; + const raw = Array.isArray(value) ? value.join(",") : value; + return raw + .split(",") + .map((id) => id.trim()) + .filter((id) => id.length > 0); +}; + +const belongsToGroup = (provider: ProviderProps, groupIds: string[]): boolean => + provider.relationships.provider_groups?.data?.some((group) => + groupIds.includes(group.id), + ) ?? false; + +/** + * Keep only providers belonging to one of the selected groups. An empty group + * list means "no group filter" and returns every provider unchanged. + */ +export const scopeProvidersByGroup = ( + providers: ProviderProps[], + groupIds: string[], +): ProviderProps[] => + groupIds.length === 0 + ? providers + : providers.filter((p) => belongsToGroup(p, groupIds)); + +/** + * Filter providers by every active scope dimension (id, type, group) combined + * with AND. Each empty dimension is skipped, so a provider is kept only when it + * satisfies all the filters that are actually set. + */ +export const filterProvidersByScope = ( + providers: ProviderProps[], + { providerIds, providerTypes, providerGroupIds }: ProviderScopeFilters, +): ProviderProps[] => { + const normalizedTypes = providerTypes.map((type) => type.toLowerCase()); + + return providers.filter((provider) => { + if (providerIds.length > 0 && !providerIds.includes(provider.id)) { + return false; + } + if ( + normalizedTypes.length > 0 && + !normalizedTypes.includes(provider.attributes.provider.toLowerCase()) + ) { + return false; + } + if ( + providerGroupIds.length > 0 && + !belongsToGroup(provider, providerGroupIds) + ) { + return false; + } + return true; + }); +}; diff --git a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx index 2f4cf64ac3..219e3c03a0 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx @@ -5,7 +5,7 @@ import { LinkToFindings } from "@/components/overview"; import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table"; import { CardTitle } from "@/components/shadcn"; import { DataTable } from "@/components/ui/table"; -import { FINDINGS_FILTERED_SORT } from "@/lib"; +import { FINDINGS_FILTERED_SORT, MUTED_FILTER } from "@/lib"; import { createDict } from "@/lib/helper"; import { FindingProps, SearchParamsProps } from "@/types"; @@ -22,6 +22,7 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) { const defaultFilters = { "filter[status]": "FAIL", "filter[delta]": "new", + "filter[muted]": MUTED_FILTER.EXCLUDE, }; const filters = pickFilterParams(searchParams); diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-pipeline-view/risk-pipeline-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-pipeline-view/risk-pipeline-view.ssr.tsx index b8479432e6..2a4b100379 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/risk-pipeline-view/risk-pipeline-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-pipeline-view/risk-pipeline-view.ssr.tsx @@ -3,11 +3,16 @@ import { getFindingsBySeverity, SeverityByProviderType, } from "@/actions/overview"; +import { OVERVIEW_FILTER_PARAM } from "@/actions/overview/overview-filters"; import { getAllProviders } from "@/actions/providers"; import { SankeyChart } from "@/components/graphs/sankey-chart"; import { SearchParamsProps } from "@/types"; import { pickFilterParams } from "../../_lib/filter-params"; +import { + parseFilterIds, + scopeProvidersByGroup, +} from "../../_lib/provider-scope"; export async function RiskPipelineViewSSR({ searchParams, @@ -16,27 +21,31 @@ export async function RiskPipelineViewSSR({ }) { const filters = pickFilterParams(searchParams); - const providerTypeFilter = filters["filter[provider_type__in]"]; - const providerIdFilter = filters["filter[provider_id__in]"]; + const providerTypeFilter = filters[OVERVIEW_FILTER_PARAM.PROVIDER_TYPE]; + const providerIdFilter = filters[OVERVIEW_FILTER_PARAM.PROVIDER_ID]; + const providerGroupsFilter = filters[OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS]; // Fetch providers list to know account types const providersListResponse = await getAllProviders(); const allProviders = providersListResponse?.data || []; + // Scope the provider set to the selected groups so we enumerate only their + // provider types below (the per-type API calls also carry the group filter). + const selectedGroupIds = parseFilterIds(providerGroupsFilter); + const scopedProviders = scopeProvidersByGroup(allProviders, selectedGroupIds); + // Build severityByProviderType based on filters const severityByProviderType: SeverityByProviderType = {}; let selectedProviderTypes: string[] | undefined; if (providerIdFilter) { // Case: Accounts are selected - group by provider type and make parallel calls - const selectedAccountIds = String(providerIdFilter) - .split(",") - .map((id) => id.trim()); + const selectedAccountIds = parseFilterIds(providerIdFilter); // Group selected accounts by provider type const accountsByType: Record = {}; for (const accountId of selectedAccountIds) { - const provider = allProviders.find((p) => p.id === accountId); + const provider = scopedProviders.find((p) => p.id === accountId); if (provider) { const type = provider.attributes.provider.toLowerCase(); if (!accountsByType[type]) { @@ -70,9 +79,9 @@ export async function RiskPipelineViewSSR({ } } else if (providerTypeFilter) { // Case: Provider types are selected - make parallel calls for each type - selectedProviderTypes = String(providerTypeFilter) - .split(",") - .map((t) => t.trim().toLowerCase()); + selectedProviderTypes = parseFilterIds(providerTypeFilter).map((type) => + type.toLowerCase(), + ); const severityPromises = selectedProviderTypes.map(async (providerType) => { const response = await getFindingsBySeverity({ @@ -93,9 +102,10 @@ export async function RiskPipelineViewSSR({ } } } else { - // Case: No filters - get all provider types and make parallel calls + // Case: No account/type filter - enumerate provider types (scoped to the + // selected groups when a group filter is active) and make parallel calls. const allProviderTypes = Array.from( - new Set(allProviders.map((p) => p.attributes.provider.toLowerCase())), + new Set(scopedProviders.map((p) => p.attributes.provider.toLowerCase())), ); const severityPromises = allProviderTypes.map(async (providerType) => { diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx index 887eb7a5d5..1f4d3625d4 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx @@ -1,5 +1,6 @@ import { Info } from "lucide-react"; +import { OVERVIEW_FILTER_PARAM } from "@/actions/overview/overview-filters"; import { adaptToRiskPlotData, getProvidersRiskData, @@ -8,6 +9,10 @@ import { getAllProviders } from "@/actions/providers"; import { SearchParamsProps } from "@/types"; import { pickFilterParams } from "../../_lib/filter-params"; +import { + filterProvidersByScope, + parseFilterIds, +} from "../../_lib/provider-scope"; import { RiskPlotClient } from "./risk-plot-client"; export async function RiskPlotSSR({ @@ -17,31 +22,19 @@ export async function RiskPlotSSR({ }) { const filters = pickFilterParams(searchParams); - const providerTypeFilter = filters["filter[provider_type__in]"]; - const providerIdFilter = filters["filter[provider_id__in]"]; - // Fetch all providers const providersListResponse = await getAllProviders(); const allProviders = providersListResponse?.data || []; - // Filter providers based on search params - let filteredProviders = allProviders; - - if (providerIdFilter) { - // Filter by specific provider IDs - const selectedIds = String(providerIdFilter) - .split(",") - .map((id) => id.trim()); - filteredProviders = allProviders.filter((p) => selectedIds.includes(p.id)); - } else if (providerTypeFilter) { - // Filter by provider types - const selectedTypes = String(providerTypeFilter) - .split(",") - .map((t) => t.trim().toLowerCase()); - filteredProviders = allProviders.filter((p) => - selectedTypes.includes(p.attributes.provider.toLowerCase()), - ); - } + // Compose every active provider-scope filter with AND so combining e.g. a + // group and a type narrows to providers matching both. + const filteredProviders = filterProvidersByScope(allProviders, { + providerIds: parseFilterIds(filters[OVERVIEW_FILTER_PARAM.PROVIDER_ID]), + providerTypes: parseFilterIds(filters[OVERVIEW_FILTER_PARAM.PROVIDER_TYPE]), + providerGroupIds: parseFilterIds( + filters[OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS], + ), + }); // No providers to show if (filteredProviders.length === 0) { diff --git a/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx b/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx index 9e0802d4ff..b65b6a21f0 100644 --- a/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx +++ b/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx @@ -3,6 +3,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; +import { OVERVIEW_FILTER_PARAM } from "@/actions/overview/overview-filters"; import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends"; import { LineChart } from "@/components/graphs/line-chart"; import { LineConfig, LineDataPoint } from "@/components/graphs/types"; @@ -42,10 +43,16 @@ export const FindingSeverityOverTime = ({ const getActiveProviderFilters = (): Record => { const filters: Record = {}; - const providerType = searchParams.get("filter[provider_type__in]"); - const providerId = searchParams.get("filter[provider_id__in]"); - if (providerType) filters["filter[provider_type__in]"] = providerType; - if (providerId) filters["filter[provider_id__in]"] = providerId; + const providerType = searchParams.get(OVERVIEW_FILTER_PARAM.PROVIDER_TYPE); + const providerId = searchParams.get(OVERVIEW_FILTER_PARAM.PROVIDER_ID); + const providerGroups = searchParams.get( + OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS, + ); + if (providerType) + filters[OVERVIEW_FILTER_PARAM.PROVIDER_TYPE] = providerType; + if (providerId) filters[OVERVIEW_FILTER_PARAM.PROVIDER_ID] = providerId; + if (providerGroups) + filters[OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS] = providerGroups; return filters; }; diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 65e8eca9cd..96c1ca05dc 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -6,6 +6,7 @@ import { getLatestFindingGroups, } from "@/actions/finding-groups"; import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings"; +import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; import { getAllProviders } from "@/actions/providers"; import { getScan, getScans } from "@/actions/scans"; import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components"; @@ -36,8 +37,9 @@ export default async function Findings({ const { encodedSort } = extractSortAndKey(resolvedSearchParams); const { filters, query } = extractFiltersAndQuery(resolvedSearchParams); - const [providersData, scansData] = await Promise.all([ + const [providersData, providerGroupsData, scansData] = await Promise.all([ getAllProviders(), + getAllProviderGroups(), getScans({ pageSize: 50 }), ]); @@ -99,6 +101,7 @@ export default async function Findings({
- - - Simple - - - - Advanced - + Simple + Advanced {simpleContent} diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx index d5e07a3813..f5de169ed6 100644 --- a/ui/app/(prowler)/page.tsx +++ b/ui/app/(prowler)/page.tsx @@ -1,8 +1,10 @@ import { Suspense } from "react"; +import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; import { getAllProviders } from "@/actions/providers"; import { getLighthouseV2Configurations } from "@/app/(prowler)/lighthouse/_actions"; import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors"; +import { ProviderGroupSelector } from "@/components/filters/provider-group-selector"; import { ContentLayout } from "@/components/ui"; import { isCloud } from "@/lib/shared/env"; import { SearchParamsProps } from "@/types"; @@ -42,15 +44,18 @@ export default async function Home({ searchParams: Promise; }) { const resolvedSearchParams = await searchParams; - const [providersData, lighthouseBannerHref] = await Promise.all([ - getAllProviders(), - getLighthouseOverviewBannerHref(isCloud(), getLighthouseV2Configurations), - ]); + const [providersData, providerGroupsData, lighthouseBannerHref] = + await Promise.all([ + getAllProviders(), + getAllProviderGroups(), + getLighthouseOverviewBannerHref(isCloud(), getLighthouseV2Configurations), + ]); return (
+
{lighthouseBannerHref ? ( diff --git a/ui/app/(prowler)/providers/page.test.ts b/ui/app/(prowler)/providers/page.test.ts index 96612380df..293ce578ce 100644 --- a/ui/app/(prowler)/providers/page.test.ts +++ b/ui/app/(prowler)/providers/page.test.ts @@ -43,4 +43,13 @@ describe("providers page", () => { expect(source).toContain("NEXT_PUBLIC_IS_CLOUD_ENV"); expect(source).toContain("{isCloudEnvironment && { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const pagePath = path.join(currentDir, "page.tsx"); + const source = readFileSync(pagePath, "utf8"); + + expect(source).toContain("SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE"); + expect(source).not.toContain("catch {\n return [];"); + }); }); diff --git a/ui/app/(prowler)/providers/page.tsx b/ui/app/(prowler)/providers/page.tsx index e6186df10a..ec9c0a7798 100644 --- a/ui/app/(prowler)/providers/page.tsx +++ b/ui/app/(prowler)/providers/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from "react"; +import { listScanConfigurations } from "@/actions/scan-configurations"; import { ProvidersAccountsView } from "@/components/providers"; import { SkeletonTableProviders } from "@/components/providers/table"; import { CliImportBanner } from "@/components/scans"; @@ -7,6 +8,10 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; import { ContentLayout } from "@/components/ui"; import { FilterTransitionWrapper } from "@/contexts"; import { SearchParamsProps } from "@/types"; +import { + SCAN_CONFIGURATION_LIST_STATUS, + type ScanConfigurationListState, +} from "@/types/scan-configurations"; import { ProviderGroupsContent } from "./provider-groups-content"; import { ProviderPageTabs } from "./provider-page-tabs"; @@ -103,23 +108,45 @@ const ProviderGroupsFallback = () => { ); }; +const loadScanConfigs = async ( + isCloud: boolean, +): Promise => { + if (!isCloud) { + return { status: SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE, data: [] }; + } + + try { + return { + status: SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE, + data: await listScanConfigurations(), + }; + } catch (error) { + console.error("Error loading provider scan configurations:", error); + return { status: SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE, data: [] }; + } +}; + const ProvidersTabContent = async ({ searchParams, }: { searchParams: SearchParamsProps; }) => { - const providersView = await loadProvidersAccountsViewData({ - searchParams, - isCloud: process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true", - }); + const isCloud = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const [providersView, scanConfigsState] = await Promise.all([ + loadProvidersAccountsViewData({ searchParams, isCloud }), + loadScanConfigs(isCloud), + ]); return ( ); }; diff --git a/ui/app/(prowler)/providers/providers-page.utils.test.ts b/ui/app/(prowler)/providers/providers-page.utils.test.ts index 6cac891480..4c712ff081 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.test.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.test.ts @@ -18,6 +18,10 @@ const schedulesActionsMock = vi.hoisted(() => ({ getSchedules: vi.fn(), })); +const manageGroupsActionsMock = vi.hoisted(() => ({ + getAllProviderGroups: vi.fn(), +})); + vi.mock("@/actions/providers", () => providersActionsMock); vi.mock( "@/actions/organizations/organizations", @@ -25,6 +29,7 @@ vi.mock( ); vi.mock("@/actions/scans", () => scansActionsMock); vi.mock("@/actions/schedules", () => schedulesActionsMock); +vi.mock("@/actions/manage-groups/manage-groups", () => manageGroupsActionsMock); import { SearchParamsProps } from "@/types"; import { ProvidersApiResponse } from "@/types/providers"; diff --git a/ui/app/(prowler)/providers/providers-page.utils.ts b/ui/app/(prowler)/providers/providers-page.utils.ts index 0b589557bb..52a56c4fe0 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.ts @@ -1,8 +1,10 @@ +import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; import { listOrganizationsSafe, listOrganizationUnitsSafe, } from "@/actions/organizations/organizations"; import { getAllProviders, getProviders } from "@/actions/providers"; +import { PROVIDERS_FILTER_PARAM } from "@/actions/providers/providers-filters"; import { getSchedules } from "@/actions/schedules"; import { extractFiltersAndQuery, @@ -484,13 +486,12 @@ export async function loadProvidersAccountsViewData({ // Map provider_type__in (used by ProviderTypeSelector) to provider__in (API param) const providerTypeFilter = - providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`]; + providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER_TYPE]; if (providerTypeFilter) { - providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER}]`] = - providerTypeFilter; + providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER] = providerTypeFilter; } - delete providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`]; + delete providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER_TYPE]; const emptyOrganizationsResponse: OrganizationListResponse = { data: [], @@ -502,6 +503,7 @@ export async function loadProvidersAccountsViewData({ const [ providersResponse, allProvidersResponse, + allProviderGroupsResponse, schedulesResponse, organizationsResponse, organizationUnitsResponse, @@ -518,6 +520,8 @@ export async function loadProvidersAccountsViewData({ // Unfiltered fetch for ProviderTypeSelector — only needs distinct types; // TODO: Replace with a dedicated lightweight endpoint when available. resolveActionResult(getAllProviders()), + // Unfiltered fetch for the Provider Group selector dropdown. + resolveActionResult(getAllProviderGroups()), // Fetch configured schedules as a fallback when provider scan_* fields are // absent (best-effort: typically empty in OSS). resolveActionResult(getSchedules()), @@ -546,6 +550,7 @@ export async function loadProvidersAccountsViewData({ filters: createProvidersFilters(), metadata: providersResponse?.meta, providers: allProvidersResponse?.data ?? [], + providerGroups: allProviderGroupsResponse?.data ?? [], rows, }; } diff --git a/ui/app/(prowler)/resources/page.tsx b/ui/app/(prowler)/resources/page.tsx index fb7e5ab63d..9c9227160c 100644 --- a/ui/app/(prowler)/resources/page.tsx +++ b/ui/app/(prowler)/resources/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from "react"; +import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; import { getAllProviders } from "@/actions/providers"; import { getLatestMetadataInfo, @@ -37,19 +38,23 @@ export default async function Resources({ const initialResourceId = resolvedSearchParams.resourceId?.toString(); - const [metadataInfoData, providersData, resourceByIdData] = await Promise.all( - [ - (hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({ - query, - filters: outputFilters, - sort: encodedSort, - }), - getAllProviders(), - initialResourceId - ? getResourceById(initialResourceId, { include: ["provider"] }) - : Promise.resolve(undefined), - ], - ); + const [ + metadataInfoData, + providersData, + providerGroupsData, + resourceByIdData, + ] = await Promise.all([ + (hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({ + query, + filters: outputFilters, + sort: encodedSort, + }), + getAllProviders(), + getAllProviderGroups(), + initialResourceId + ? getResourceById(initialResourceId, { include: ["provider"] }) + : Promise.resolve(undefined), + ]); const processedResource = resourceByIdData?.data ? (() => { @@ -80,6 +85,7 @@ export default async function Resources({
void; + richProviders: ProviderProps[]; + existingConfigs: ScanConfigurationData[]; + config: ScanConfigurationData | null; +} + +interface ScanConfigurationFormProps { + onClose: (saved: boolean) => void; + richProviders: ProviderProps[]; + existingConfigs: ScanConfigurationData[]; + config: ScanConfigurationData | null; +} + +// `provider_ids` has a zod `.default([])`, so the resolver's input and output +// types differ — type the form with both so RHF and zodResolver line up. +type ScanConfigurationFormInput = z.input; +type ScanConfigurationFormValues = z.output; + +function ScanConfigurationForm({ + onClose, + richProviders, + existingConfigs, + config, +}: ScanConfigurationFormProps) { + const isEdit = !!config; + const { toast } = useToast(); + const errorPanelRef = useRef(null); + + // The form is remounted every time the modal opens (Radix unmounts the + // dialog content on close), so deriving the defaults from `config` here is + // enough to reset the form — no `useEffect` needed. + const form = useForm< + ScanConfigurationFormInput, + unknown, + ScanConfigurationFormValues + >({ + resolver: zodResolver(scanConfigurationFormSchema), + defaultValues: config + ? { + name: config.attributes.name, + configuration: convertToYaml(config.attributes.configuration || ""), + provider_ids: config.attributes.providers || [], + } + : { name: "", configuration: "", provider_ids: [] }, + }); + + const configText = form.watch("configuration") || ""; + const selectedProviders = form.watch("provider_ids") || []; + + // Mirror the Mutelist editor: the client validates YAML *syntax* live (that it + // parses to a mapping); the API validates the configuration values + // (ranges/enums) on save and returns them inline. Skip while empty so we don't + // flag an error before the user types. + const yamlSyntax = configText.trim() + ? validateYaml(configText) + : { isValid: true as const }; + + // A provider can only be attached to one config at a time. We exclude + // providers that are owned by *other* configs from the selector so the user + // can't double-attach them. (AccountsSelector doesn't expose a per-option + // disabled state, so filtering out is the cleanest contract here.) + const ownerByProvider = new Map(); + for (const c of existingConfigs) { + if (config && c.id === config.id) continue; + for (const pid of c.attributes.providers || []) { + ownerByProvider.set(pid, c.attributes.name); + } + } + const selectableProviders = richProviders.filter( + (p) => !ownerByProvider.has(p.id), + ); + const lockedCount = richProviders.length - selectableProviders.length; + + const onSubmit = form.handleSubmit(async (values) => { + // Block on a YAML syntax error (the inline message already explains it); the + // API validates the values on save and returns any errors inline. + if (!yamlSyntax.isValid) { + errorPanelRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + return; + } + + const formData = new FormData(); + formData.append("name", values.name.trim()); + formData.append("configuration", values.configuration); + values.provider_ids.forEach((pid) => { + formData.append("provider_ids", pid); + }); + if (config) { + formData.append("id", config.id); + } + + try { + const result = config + ? await updateScanConfiguration(null, formData) + : await createScanConfiguration(null, formData); + + if (result?.success) { + toast({ + title: isEdit + ? "Scan Configuration updated" + : "Scan Configuration created", + description: result.success, + }); + onClose(true); + return; + } + + // Field-level errors render inline next to each input; only a general + // error (no field to anchor it to) falls back to a toast. + const errors = result?.errors || {}; + if (errors.name) form.setError("name", { message: errors.name }); + if (errors.configuration) + form.setError("configuration", { message: errors.configuration }); + if (errors.provider_ids) + form.setError("provider_ids", { message: errors.provider_ids }); + if (errors.general) { + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errors.general, + }); + } + } catch (e) { + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: + e instanceof Error ? e.message : "Unexpected error. Please retry.", + }); + } + }); + + const isSubmitting = form.formState.isSubmitting; + const nameError = form.formState.errors.name?.message; + const configError = form.formState.errors.configuration?.message; + const providersError = form.formState.errors.provider_ids?.message; + + return ( +
+ + Name + + {nameError && {nameError}} + + + + + Configuration (YAML) + +
    +
  • + Follows the structure of{" "} + + prowler/config/config.yaml + + ; include only the keys you want to override. +
  • +
  • The configuration is validated on save.
  • +
  • + Learn more about configuring scans{" "} + + here + + . +
  • +
+